From 851b031f97f486ed57208b39d1d059017433bac8 Mon Sep 17 00:00:00 2001 From: Hyunwoo Jung Date: Fri, 13 Nov 2026 17:07:20 +0900 Subject: [PATCH] tailcat: keep netns on for macOS so a co-located VPN is bypassed tailcat turned netns off on every platform, so its sockets followed the system route table. On a Mac running the Tailscale app with an exit node selected, that table sends tailcat's own WireGuard UDP and DERP connections into the app's tunnel, and tailcat's direct path then runs inside it. On macOS netns binds sockets to the default physical interface, as tailscaled does, and loopback UDP still works from sockets bound that way, so leave it on there. Other platforms keep it off, as configureNetNS explains. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Fixes #165 Signed-off-by: Hyunwoo Jung Co-Authored-By: Claude Co-Authored-By: Happy --- pickregion.go | 13 ++++--------- tailcat.go | 22 +++++++++++++++++++++- tailcat_test.go | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/pickregion.go b/pickregion.go index 1a954d9b7..c246ece54 100644 --- a/pickregion.go +++ b/pickregion.go @@ -13,7 +13,6 @@ import ( "tailscale.com/net/netcheck" "tailscale.com/net/netmon" - "tailscale.com/net/netns" "tailscale.com/tailcfg" "tailscale.com/types/logger" ) @@ -23,14 +22,10 @@ import ( // nil error) if the netcheck report contained no usable region // latencies. func PickBestRegion(ctx context.Context, dm *tailcfg.DERPMap) (regionID tailcfg.DERPRegionID, err error) { - // Disable netns before netcheck binds its STUN sockets, not just - // at engine creation as createEngine does, which runs after this. - // tailcat has no TUN device whose routes netns would need to - // avoid, and netns's unprivileged fallback (SO_BINDTODEVICE to - // the default interface) blackholes loopback UDP, making every - // netcheck against a loopback DERP map burn the full 3-second - // STUN timeout before falling back to HTTPS probes. - netns.SetEnabled(false) + // Set the netns policy before netcheck binds its STUN sockets, not + // just at engine creation as createEngine does, which runs after + // this. See configureNetNS for why it depends on the OS. + configureNetNS() nc := &netcheck.Client{ NetMon: netmon.NewStatic(), Verbose: Verbose, diff --git a/tailcat.go b/tailcat.go index 7585d3ace..6a1a4ad74 100644 --- a/tailcat.go +++ b/tailcat.go @@ -1905,7 +1905,7 @@ func createEngine(logf logger.Logf, lb *locoBackend) (err error) { // call-me-maybe messages that advertise our UDP endpoints (see // locoBackend.advertiseEndpoints). conf.ForceDiscoKey = discoPrivateForNode(lb.priv) - netns.SetEnabled(false) + configureNetNS() e, err := wgengine.NewUserspaceEngine(logf, conf) if err != nil { logf("wgengine.NewUserspaceEngine(tun %q) error: %v", "userspace-networking", err) @@ -1916,6 +1916,26 @@ func createEngine(logf logger.Logf, lb *locoBackend) (err error) { return nil } +// configureNetNS sets the process-wide netns policy before tailcat opens +// any sockets. +// +// On macOS netns stays on. It binds tailcat's UDP sockets and DERP +// connections to the default physical interface, as tailscaled does, so +// a co-located VPN that takes the default route, such as the Tailscale +// app with an exit node selected, does not carry tailcat's WireGuard +// packets inside its own tunnel. Loopback traffic still works from +// sockets bound this way. +// +// Other platforms turn netns off. On Linux, netns's unprivileged fallback +// (SO_BINDTODEVICE to the default interface) blackholes loopback UDP, +// making every netcheck against a loopback DERP map burn the full +// 3-second STUN timeout before falling back to HTTPS probes. +func configureNetNS() { + if runtime.GOOS != "darwin" { + netns.SetEnabled(false) + } +} + // Client connects to a [Server] over a WireGuard tunnel relayed through DERP. // Populate Server (the only required field, or use the [NewClient] // shorthand), then just dial: [Client.Dial], the DialTCP methods, and the diff --git a/tailcat_test.go b/tailcat_test.go index 6b7e7b285..bae47fbae 100644 --- a/tailcat_test.go +++ b/tailcat_test.go @@ -16,6 +16,7 @@ import ( "net/http/httptest" "net/netip" "os" + "runtime" "strings" "sync" "sync/atomic" @@ -35,6 +36,8 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" "tailscale.com/envknob" "tailscale.com/net/netcheck" + "tailscale.com/net/netmon" + "tailscale.com/net/netns" "tailscale.com/syncs" "tailscale.com/tailcfg" "tailscale.com/tstest/integration" @@ -1682,3 +1685,21 @@ func TestPeerKey(t *testing.T) { } } } + +// TestNetNSPolicy checks the netns policy that configureNetNS sets when a +// Server starts. On macOS netns stays on, so tailcat's sockets bind to the +// default physical interface instead of following a co-located VPN's +// default route. Elsewhere it is off. +func TestNetNSPolicy(t *testing.T) { + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + s := &Server{Key: key.NewNode(), Logf: mkLogger(t, "server"), Region: dm.Regions[1]} + t.Cleanup(func() { s.Close() }) + if err := s.Start(); err != nil { + t.Fatalf("server Start: %v", err) + } + + bound := netns.Listener(t.Logf, netmon.NewStatic()).Control != nil + if want := runtime.GOOS == "darwin"; bound != want { + t.Errorf("on %s after Server.Start, netns binds sockets = %v; want %v", runtime.GOOS, bound, want) + } +}