Skip to content

clawpatrol run (per-process) can't reach tunnel-backed endpoints (kubernetes_port_forward / local_command) #765

Description

@magurotuna

Summary

In per-process mode (clawpatrol run, tsnet transport, no --whole-machine), tunnel-backed endpoints (kubernetes_port_forward, local_command) are unreachable, while direct endpoints (HTTPS via SNI, k8s API by IP + bearer) work. The same endpoints work fine from a whole-machine WireGuard client on the same gateway/profile. Root cause is per-process DNS not reaching the gateway's DNS-VIP allocator, not an architectural limit.

Repro (per-process Linux client, tsnet gateway)

# direct endpoints — OK
clawpatrol run -- curl -so/dev/null -w '%{http_code}' https://api.github.com/user     # 200
clawpatrol run -- kubectl --server=https://<k8s-api-ip> --token=x get --raw=/version  # OK

# tunnel-backed postgres — FAIL
# (endpoint routed through a kubernetes_port_forward tunnel)
clawpatrol run -- getent hosts <endpoint-fqdn>      # → raw upstream IP, NOT an fd78:: VIP
clawpatrol run -- psql "host=<endpoint-fqdn> ..."   # TCP connects, protocol handshake times out
# (endpoint routed through a local_command tunnel; host is a private name)
clawpatrol run -- psql "host=<private-fqdn> ..."    # "could not translate host name"

Whole-machine client: the same names resolve to fd78::* VIPs and connect fine.

Root cause

Tunnel-backed endpoints are routed only by DNS-VIP interception (RequiresVIP() is true whenever ce.Tunnel != nil, internal/config/compile.go:161-172; postgres always, endpoints/postgres.go:95). They are deliberately excluded from real-IP routing (internal/config/runtime/conn_route.go:66-73: if ep.Tunnel != nil { continue }). So if the agent's DNS returns the real IP instead of the VIP, the connection hits the gateway's "no endpoint claims dstIP → relay verbatim" branch (cmd/clawpatrol/main.go:1818-1823) and is relayed to the raw upstream IP, which isn't the tunnel — hence TCP-open-then-timeout.

Why per-process DNS returns the raw IP: the child netns rewrites nsswitch.conf/resolv.conf to force queries at the gateway resolver, but this is best-effort and non-fatal (cmd/clawpatrol/run_linux.go:384-397, 953-1061). When it doesn't take, glibc getaddrinfo is answered by the host's systemd-resolved via the host's upstream DNS (real IP for public names; private names → NXDOMAIN) and never enters the TUN, bypassing the gateway's dnsvip allocator entirely.

The gateway-side machinery is transport-agnostic: VIP allocation is global (dnsvip/dnsvip.go:342), the tsnet fallback routes VIPs into handleVIPConn exactly like WG (main.go:3646-3647), and TunnelManager/dialThrough never reference WG vs tsnet. No code gates tunnels to whole-machine mode — this is a per-process DNS-interception gap (the details differ per-OS; see below).

Suggested direction

The core requirement is transport-independent: an agent's DNS query for a tunneled name must be answered by the gateway's dnsvip allocator, never by the host's system resolver — otherwise it gets the real IP, which for a tunneled endpoint is a black hole (per the routing above). Today per-process DNS can leak to the host resolver on both OSes:

  • Linux: the child-netns nsswitch.conf sanitization that keeps glibc from consulting systemd-resolved ahead of the bind-mounted resolv.conf is best-effort and non-fatal (run_linux.go:384-397, 905-1000); when it doesn't take, getaddrinfo is answered off-tunnel. The robust fix is to stop depending on NSS ordering — have the per-host daemon (the existing clawpatrol daemon-internal, which already owns the TUN and the single tsnet/WG peer) act as the netns's resolver and forward to the gateway, so DNS rides the same tunnel as TCP regardless of distro NSS config. Fail-closed is then a safety net, not the primary mechanism.
  • macOS: the transparent-proxy path already rewrites child UDP/53 to <gateway>:53 so it reaches serveTsnetDNSUDP (ClawpatrolExtension/Provider.swift:40-49), but apps that resolve via mDNSResponder/system getaddrinfo bypass NE entirely and still get an off-tunnel answer — the same root cause. The same principle applies: ensure system-resolver lookups for tunneled names are intercepted, not just an app's own UDP/53.

Separately, once the VIP is returned, confirm it is routable from the client's tsnet under exit-node: the v4 VIP 10.78.0.0/16 sits inside 10.0.0.0/8, which Tailscale treats as LAN and won't send to the exit node — the fd78:: v6 VIP avoids this (client-side mirror of the #653 gateway-side fix). This is internal to the tunnel/netstack and does not require the host to have public IPv6.

No schema/protocol/trust-model change required; the gateway-side tunnel + VIP machinery already works for whole-machine clients.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions