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.
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)
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 wheneverce.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.confto 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, glibcgetaddrinfois 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'sdnsvipallocator entirely.The gateway-side machinery is transport-agnostic: VIP allocation is global (
dnsvip/dnsvip.go:342), the tsnet fallback routes VIPs intohandleVIPConnexactly like WG (main.go:3646-3647), andTunnelManager/dialThroughnever 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
dnsvipallocator, 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:nsswitch.confsanitization that keeps glibc from consulting systemd-resolved ahead of the bind-mountedresolv.confis best-effort and non-fatal (run_linux.go:384-397, 905-1000); when it doesn't take,getaddrinfois answered off-tunnel. The robust fix is to stop depending on NSS ordering — have the per-host daemon (the existingclawpatrol 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.<gateway>:53so it reachesserveTsnetDNSUDP(ClawpatrolExtension/Provider.swift:40-49), but apps that resolve viamDNSResponder/systemgetaddrinfobypass 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/16sits inside10.0.0.0/8, which Tailscale treats as LAN and won't send to the exit node — thefd78::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.