From 9caa916c5a455b88f46360cfc088c65cfbaf6a4e Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 23 Jul 2025 03:51:41 +0000 Subject: [PATCH 1/6] fix: change interface detection to use Coder IPs Co-authored-by: than Dickson --- net/interfaces/interfaces.go | 14 ++++++-------- net/tsaddr/tsaddr.go | 35 ++++++++++++---------------------- wgengine/magicsock/endpoint.go | 3 ++- 3 files changed, 20 insertions(+), 32 deletions(-) diff --git a/net/interfaces/interfaces.go b/net/interfaces/interfaces.go index 7923853173d9d..e5372390bbf83 100644 --- a/net/interfaces/interfaces.go +++ b/net/interfaces/interfaces.go @@ -24,7 +24,7 @@ import ( // which HTTP proxy the system should use. var LoginEndpointForProxyDetermination = "https://controlplane.tailscale.com/" -// Tailscale returns the current machine's Tailscale interface, if any. +// Tailscale returns the current machine's Coder interface, if any. // If none is found, all zero values are returned. // A non-nil error is only returned on a problem listing the system interfaces. func Tailscale() ([]netip.Addr, *net.Interface, error) { @@ -58,12 +58,10 @@ func Tailscale() ([]netip.Addr, *net.Interface, error) { } // maybeTailscaleInterfaceName reports whether s is an interface -// name that might be used by Tailscale. +// name that might be used by Coder. func maybeTailscaleInterfaceName(s string) bool { - return s == "Tailscale" || - strings.HasPrefix(s, "wg") || - strings.HasPrefix(s, "ts") || - strings.HasPrefix(s, "tailscale") || + return s == "Coder" || + strings.HasPrefix(s, "coder") || strings.HasPrefix(s, "utun") } @@ -496,8 +494,8 @@ func isTailscaleInterface(name string, ips []netip.Prefix) bool { // macOS NetworkExtensions and utun devices. return true } - return name == "Tailscale" || // as it is on Windows - strings.HasPrefix(name, "tailscale") // TODO: use --tun flag value, etc; see TODO in method doc + return name == "Coder" || // as it is on Windows + strings.HasPrefix(name, "coder") // TODO: use --tun flag value, etc; see TODO in method doc } // getPAC, if non-nil, returns the current PAC file URL. diff --git a/net/tsaddr/tsaddr.go b/net/tsaddr/tsaddr.go index d35fce09994c3..26b544368b64c 100644 --- a/net/tsaddr/tsaddr.go +++ b/net/tsaddr/tsaddr.go @@ -73,48 +73,37 @@ const ( CoderServiceIPv6String = "fd60:627a:a42b::53" ) +// These are all unfortunately Coder IP ranges, not Tailscale IP ranges. + // IsTailscaleIP reports whether ip is an IP address in a range that -// Tailscale assigns from. +// Coder assigns from. func IsTailscaleIP(ip netip.Addr) bool { - if ip.Is4() { - return CGNATRange().Contains(ip) && !ChromeOSVMRange().Contains(ip) - } return TailscaleULARange().Contains(ip) } // TailscaleULARange returns the IPv6 Unique Local Address range that -// is the superset range that Tailscale assigns out of. +// is the superset range that Coder assigns out of. func TailscaleULARange() netip.Prefix { - tsUlaRange.Do(func() { mustPrefix(&tsUlaRange.v, "fd7a:115c:a1e0::/48") }) + tsUlaRange.Do(func() { mustPrefix(&tsUlaRange.v, "fd60:627a:a42b::/48") }) return tsUlaRange.v } -// TailscaleViaRange returns the IPv6 Unique Local Address subset range -// TailscaleULARange that's used for IPv4 tunneling via IPv6. +// Unused by Coder func TailscaleViaRange() netip.Prefix { - // Mnemonic: "b1a" sounds like "via". - tsViaRange.Do(func() { mustPrefix(&tsViaRange.v, "fd7a:115c:a1e0:b1a::/64") }) + tsViaRange.Do(func() { mustPrefix(&tsViaRange.v, "fd60:627a:a42b::/128") }) return tsViaRange.v } -// Tailscale4To6Range returns the subset of TailscaleULARange used for -// auto-translated Tailscale ipv4 addresses. +// Unused by Coder func Tailscale4To6Range() netip.Prefix { - // This IP range has no significance, beyond being a subset of - // TailscaleULARange. The bits from /48 to /104 were picked at - // random. - ula4To6Range.Do(func() { mustPrefix(&ula4To6Range.v, "fd7a:115c:a1e0:ab12:4843:cd96:6200::/104") }) + // This needs to be a /104 prefix, so it can fit IPv4 addresses + ula4To6Range.Do(func() { mustPrefix(&ula4To6Range.v, "fd60:627a:a42b::/104") }) return ula4To6Range.v } -// TailscaleEphemeral6Range returns the subset of TailscaleULARange -// used for ephemeral IPv6-only Tailscale nodes. +// Unused by Coder func TailscaleEphemeral6Range() netip.Prefix { - // This IP range has no significance, beyond being a subset of - // TailscaleULARange. The bits from /48 to /64 were picked at - // random, with the only criterion being to not be the conflict - // with the Tailscale4To6Range above. - ulaEph6Range.Do(func() { mustPrefix(&ulaEph6Range.v, "fd7a:115c:a1e0:efe3::/64") }) + ulaEph6Range.Do(func() { mustPrefix(&ulaEph6Range.v, "fd60:627a:a42b::/128") }) return ulaEph6Range.v } diff --git a/wgengine/magicsock/endpoint.go b/wgengine/magicsock/endpoint.go index c967130d8109d..ce8dab8cabccd 100644 --- a/wgengine/magicsock/endpoint.go +++ b/wgengine/magicsock/endpoint.go @@ -24,6 +24,7 @@ import ( "tailscale.com/disco" "tailscale.com/ipn/ipnstate" "tailscale.com/net/stun" + "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/tstime/mono" "tailscale.com/types/key" @@ -1064,7 +1065,7 @@ func (de *endpoint) handleCallMeMaybe(m *disco.CallMeMaybe) { } var newEPs []netip.AddrPort for _, ep := range m.MyNumber { - if ep.Addr().Is6() && ep.Addr().IsLinkLocalUnicast() { + if (ep.Addr().Is6() && ep.Addr().IsLinkLocalUnicast()) || tsaddr.IsTailscaleIP(ep.Addr()) { // We send these out, but ignore them for now. // TODO: teach the ping code to ping on all interfaces // for these. From 596984d683676bfcaed30e2e33bc852158a08b58 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 23 Jul 2025 03:58:32 +0000 Subject: [PATCH 2/6] fix: add soft isolation mode and windows impl - Adds new switch to netns called SetCoderSoftIsolation - Adds a branch to the windows socket control function to determine whether soft isolation can be used for the given socket --- net/netns/netns.go | 23 +++++++++++ net/netns/netns_windows.go | 85 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/net/netns/netns.go b/net/netns/netns.go index 2acb151298e25..0caa304a82fee 100644 --- a/net/netns/netns.go +++ b/net/netns/netns.go @@ -53,6 +53,29 @@ func SetDisableBindConnToInterface(v bool) { disableBindConnToInterface.Store(v) } +var coderSoftIsolation atomic.Bool + +// SetCoderSoftIsolation enables or disables Coder's soft-isolation +// functionality. All other network isolation settings are ignored when this is +// set. +// +// Soft isolation does the following: +// 1. Determine the interface that will be used for a given destination IP by +// consulting the OS. +// 2. If that interface looks like our own, we will bind the socket to the +// default interface (to match the existing behavior). +// 3. If it doesn't look like our own, we will let the packet flow through +// without binding the socket to the interface. +// +// This is considered "soft" because it doesn't force the socket to be bound to +// a single interface, which causes problems with direct connections in +// magicsock. +// +// This currently only has an effect on Windows and macOS. +func SetCoderSoftIsolation(v bool) { + coderSoftIsolation.Store(v) +} + // Listener returns a new net.Listener with its Control hook func // initialized as necessary to run in logical network namespace that // doesn't route back into Tailscale. diff --git a/net/netns/netns_windows.go b/net/netns/netns_windows.go index 8aa1da18d5a7d..96de965453be0 100644 --- a/net/netns/netns_windows.go +++ b/net/netns/netns_windows.go @@ -4,7 +4,11 @@ package netns import ( + "fmt" "math/bits" + "net" + "net/netip" + "strconv" "strings" "syscall" @@ -27,13 +31,15 @@ func interfaceIndex(iface *winipcfg.IPAdapterAddresses) uint32 { return iface.IfIndex } -func control(logger.Logf, *netmon.Monitor) func(network, address string, c syscall.RawConn) error { - return controlC +func control(logf logger.Logf, netMon *netmon.Monitor) func(network, address string, c syscall.RawConn) error { + return func(network, address string, c syscall.RawConn) error { + return controlLogf(logf, netMon, network, address, c) + } } // controlC binds c to the Windows interface that holds a default // route, and is not the Tailscale WinTun interface. -func controlC(network, address string, c syscall.RawConn) error { +func controlLogf(logf logger.Logf, _ *netmon.Monitor, network, address string, c syscall.RawConn) error { if strings.HasPrefix(address, "127.") { // Don't bind to an interface for localhost connections, // otherwise we get: @@ -51,6 +57,42 @@ func controlC(network, address string, c syscall.RawConn) error { canV6 = true } + if coderSoftIsolation.Load() { + sockAddr, err := getSockAddr(address) + if err != nil { + logf("netns: Coder soft isolation: error getting sockaddr: %v", err) + return err + } + if sockAddr == nil { + // Sockets bound like :0 or :1234 cannot be checked. + return nil + } + + // Ask Windows to find the best interface for this address by consulting + // the routing table. + // + // On macOS this value gets cached, but on Windows we don't need to + // because this API is very fast and doesn't require opening an AF_ROUTE + // socket. + var idx uint32 + err = windows.GetBestInterfaceEx(sockAddr, &idx) + if err != nil { + logf("netns: Coder soft isolation: error getting best interface: %v", err) + return err + } + + _, tsif, err2 := interfaces.Tailscale() + if err2 == nil && tsif != nil && tsif.Index == int(idx) { + logf("[unexpected] netns: Coder soft isolation: detected Tailscale interface") + // No return, we want to run the code below to bind this socket to + // the default interface. + } else { + // It doesn't look like our own interface, so we return early to + // prevent the socket from being bound to the default interface. + return nil + } + } + if canV4 { iface, err := interfaces.GetWindowsDefault(windows.AF_INET) if err != nil { @@ -124,3 +166,40 @@ func nativeToBigEndian(i uint32) uint32 { } return bits.ReverseBytes32(i) } + +// getSockAddr returns the Windows sockaddr for the given address, or nil if +// the address is not specified. +func getSockAddr(address string) (windows.Sockaddr, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("invalid address %q: %w", address, err) + } + if host == "" { + // Sockets bound like :0 or :1234 cannot be checked. + return nil, nil + } + portInt, err := strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("invalid port %q: %w", port, err) + } + addr, err := netip.ParseAddr(host) + if err != nil { + return nil, fmt.Errorf("invalid address %q: %w", address, err) + } + + if addr.Is4() { + return &windows.SockaddrInet4{ + Port: portInt, + Addr: addr.As4(), + }, nil + } else if addr.Is6() { + if addr.Zone() != "" { + return nil, fmt.Errorf("invalid address %q, has zone: %w", address, err) + } + return &windows.SockaddrInet6{ + Port: portInt, + Addr: addr.As16(), + }, nil + } + return nil, fmt.Errorf("invalid address %q, is not IPv4 or IPv6: %w", address, err) +} From e3b51e0eb843c6975f6ae3f646b109afa8b00f3a Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 23 Jul 2025 07:46:26 +0000 Subject: [PATCH 3/6] fix: switch to specific tsaddr.Coder* functions, make tests pass --- control/controlbase/conn_test.go | 4 +- net/dns/nm.go | 2 +- net/interfaces/interfaces.go | 10 ++--- net/netmon/netmon_linux.go | 6 +-- net/netmon/netmon_windows.go | 4 +- net/netns/netns_darwin.go | 6 +-- net/netns/netns_darwin_test.go | 2 +- net/netns/netns_windows.go | 4 +- net/tsaddr/tsaddr.go | 61 ++++++++++++++++++-------- tsnet/tsnet_test.go | 2 + tstest/integration/integration_test.go | 2 + wgengine/magicsock/endpoint.go | 2 +- wgengine/magicsock/magicsock_test.go | 2 + wgengine/netlog/logger.go | 2 +- wgengine/netstack/netstack.go | 2 +- wgengine/netstack/netstack_test.go | 4 +- wgengine/userspace.go | 4 +- wgengine/wgcfg/nmcfg/nmcfg.go | 2 +- 18 files changed, 75 insertions(+), 46 deletions(-) diff --git a/control/controlbase/conn_test.go b/control/controlbase/conn_test.go index 079c57c6e5f2e..4d6852d8a54af 100644 --- a/control/controlbase/conn_test.go +++ b/control/controlbase/conn_test.go @@ -228,7 +228,7 @@ func TestConnStd(t *testing.T) { } // tests that the idle memory overhead of a Conn blocked in a read is -// reasonable (under 2K). It was previously over 8KB with two 4KB +// reasonable (under 2.5K). It was previously over 8KB with two 4KB // buffers for rx/tx. This make sure we don't regress. Hopefully it // doesn't turn into a flaky test. If so, const max can be adjusted, // or it can be deleted or reworked. @@ -281,7 +281,7 @@ func TestConnMemoryOverhead(t *testing.T) { growthTotal := int64(ms.HeapAlloc) - int64(ms0.HeapAlloc) growthEach := float64(growthTotal) / float64(num) t.Logf("Alloced %v bytes, %.2f B/each", growthTotal, growthEach) - const max = 2000 + const max = 2500 if growthEach > max { t.Errorf("allocated more than expected; want max %v bytes/each", max) } diff --git a/net/dns/nm.go b/net/dns/nm.go index 664297c63089e..6ea26b83eab3d 100644 --- a/net/dns/nm.go +++ b/net/dns/nm.go @@ -139,7 +139,7 @@ func (m *nmManager) trySet(ctx context.Context, config OSConfig) error { // tell it explicitly to keep it. Read out the current interface // settings and mirror them out to NetworkManager. var addrs6 []map[string]any - addrs, _, err := interfaces.Tailscale() + addrs, _, err := interfaces.Coder() if err == nil { for _, a := range addrs { if a.Is6() { diff --git a/net/interfaces/interfaces.go b/net/interfaces/interfaces.go index e5372390bbf83..70b4e6bf4b3eb 100644 --- a/net/interfaces/interfaces.go +++ b/net/interfaces/interfaces.go @@ -24,10 +24,10 @@ import ( // which HTTP proxy the system should use. var LoginEndpointForProxyDetermination = "https://controlplane.tailscale.com/" -// Tailscale returns the current machine's Coder interface, if any. +// Coder returns the current machine's Coder interface, if any. // If none is found, all zero values are returned. // A non-nil error is only returned on a problem listing the system interfaces. -func Tailscale() ([]netip.Addr, *net.Interface, error) { +func Coder() ([]netip.Addr, *net.Interface, error) { ifs, err := netInterfaces() if err != nil { return nil, nil, err @@ -45,7 +45,7 @@ func Tailscale() ([]netip.Addr, *net.Interface, error) { if ipnet, ok := a.(*net.IPNet); ok { nip, ok := netip.AddrFromSlice(ipnet.IP) nip = nip.Unmap() - if ok && tsaddr.IsTailscaleIP(nip) { + if ok && tsaddr.IsCoderIP(nip) { tsIPs = append(tsIPs, nip) } } @@ -118,7 +118,7 @@ func LocalAddresses() (regular, loopback []netip.Addr, err error) { // very well be something we can route to // directly, because both nodes are // behind the same CGNAT router. - if tsaddr.IsTailscaleIP(ip) { + if tsaddr.IsCoderIP(ip) { continue } if ip.IsLoopback() || ifcIsLoopback { @@ -477,7 +477,7 @@ func (s *State) AnyInterfaceUp() bool { func hasTailscaleIP(pfxs []netip.Prefix) bool { for _, pfx := range pfxs { - if tsaddr.IsTailscaleIP(pfx.Addr()) { + if tsaddr.IsCoderIP(pfx.Addr()) { return true } } diff --git a/net/netmon/netmon_linux.go b/net/netmon/netmon_linux.go index dd23dd34263c5..b1ae05df366e7 100644 --- a/net/netmon/netmon_linux.go +++ b/net/netmon/netmon_linux.go @@ -178,7 +178,7 @@ func (c *nlConn) Receive() (message, error) { if rmsg.Table == tsTable && dst.IsSingleIP() { // Don't log. Spammy and normal to see a bunch of these on start-up, // which we make ourselves. - } else if tsaddr.IsTailscaleIP(dst.Addr()) { + } else if tsaddr.IsCoderIP(dst.Addr()) { // Verbose only. c.logf("%s: [v1] src=%v, dst=%v, gw=%v, outif=%v, table=%v", typeStr, condNetAddrPrefix(src), condNetAddrPrefix(dst), condNetAddrIP(gw), @@ -271,7 +271,7 @@ type newRouteMessage struct { const tsTable = 52 func (m *newRouteMessage) ignore() bool { - return m.Table == tsTable || tsaddr.IsTailscaleIP(m.Dst.Addr()) + return m.Table == tsTable || tsaddr.IsCoderIP(m.Dst.Addr()) } // newAddrMessage is a message for a new address being added. @@ -282,7 +282,7 @@ type newAddrMessage struct { } func (m *newAddrMessage) ignore() bool { - return tsaddr.IsTailscaleIP(m.Addr) + return tsaddr.IsCoderIP(m.Addr) } type ignoreMessage struct{} diff --git a/net/netmon/netmon_windows.go b/net/netmon/netmon_windows.go index 8369222063dcf..b7b7fe99ba940 100644 --- a/net/netmon/netmon_windows.go +++ b/net/netmon/netmon_windows.go @@ -131,7 +131,7 @@ func (m *winMon) Receive() (message, error) { // unicastAddressChanged is the callback we register with Windows to call when unicast address changes. func (m *winMon) unicastAddressChanged(_ winipcfg.MibNotificationType, row *winipcfg.MibUnicastIPAddressRow) { what := "addr" - if ip := row.Address.Addr(); ip.IsValid() && tsaddr.IsTailscaleIP(ip.Unmap()) { + if ip := row.Address.Addr(); ip.IsValid() && tsaddr.IsCoderIP(ip.Unmap()) { what = "tsaddr" } @@ -143,7 +143,7 @@ func (m *winMon) unicastAddressChanged(_ winipcfg.MibNotificationType, row *wini func (m *winMon) routeChanged(_ winipcfg.MibNotificationType, row *winipcfg.MibIPforwardRow2) { what := "route" ip := row.DestinationPrefix.Prefix().Addr().Unmap() - if ip.IsValid() && tsaddr.IsTailscaleIP(ip) { + if ip.IsValid() && tsaddr.IsCoderIP(ip) { what = "tsroute" } // start a goroutine to finish our work, to return to Windows out of this callback diff --git a/net/netns/netns_darwin.go b/net/netns/netns_darwin.go index b32a744b7ee8e..0bcd84a21c0a3 100644 --- a/net/netns/netns_darwin.go +++ b/net/netns/netns_darwin.go @@ -108,11 +108,11 @@ func getInterfaceIndex(logf logger.Logf, netMon *netmon.Monitor, address string) return defaultIdx() } - // Verify that we didn't just choose the Tailscale interface; + // Verify that we didn't just choose the Coder interface; // if so, we fall back to binding from the default. - _, tsif, err2 := interfaces.Tailscale() + _, tsif, err2 := interfaces.Coder() if err2 == nil && tsif != nil && tsif.Index == idx { - logf("[unexpected] netns: interfaceIndexFor returned Tailscale interface") + logf("[unexpected] netns: interfaceIndexFor returned Coder interface") return defaultIdx() } diff --git a/net/netns/netns_darwin_test.go b/net/netns/netns_darwin_test.go index 0fc92f6f3888f..e11b3cba6bb31 100644 --- a/net/netns/netns_darwin_test.go +++ b/net/netns/netns_darwin_test.go @@ -55,7 +55,7 @@ func TestGetInterfaceIndex(t *testing.T) { } t.Run("NoTailscale", func(t *testing.T) { - _, tsif, err := interfaces.Tailscale() + _, tsif, err := interfaces.Coder() if err != nil { t.Fatal(err) } diff --git a/net/netns/netns_windows.go b/net/netns/netns_windows.go index 96de965453be0..1d889b63fe8ad 100644 --- a/net/netns/netns_windows.go +++ b/net/netns/netns_windows.go @@ -81,9 +81,9 @@ func controlLogf(logf logger.Logf, _ *netmon.Monitor, network, address string, c return err } - _, tsif, err2 := interfaces.Tailscale() + _, tsif, err2 := interfaces.Coder() if err2 == nil && tsif != nil && tsif.Index == int(idx) { - logf("[unexpected] netns: Coder soft isolation: detected Tailscale interface") + logf("[unexpected] netns: Coder soft isolation: detected socket bound to Coder interface") // No return, we want to run the code below to bind this socket to // the default interface. } else { diff --git a/net/tsaddr/tsaddr.go b/net/tsaddr/tsaddr.go index 26b544368b64c..2daff12b169f2 100644 --- a/net/tsaddr/tsaddr.go +++ b/net/tsaddr/tsaddr.go @@ -35,14 +35,16 @@ func CGNATRange() netip.Prefix { } var ( - cgnatRange oncePrefix - ulaRange oncePrefix - tsUlaRange oncePrefix - tsViaRange oncePrefix - ula4To6Range oncePrefix - ulaEph6Range oncePrefix - serviceIPv6 oncePrefix + cgnatRange oncePrefix + ulaRange oncePrefix + tsUlaRange oncePrefix + tsViaRange oncePrefix + ula4To6Range oncePrefix + ulaEph6Range oncePrefix + serviceIPv6 oncePrefix + coderServiceIPv6 oncePrefix + coderV6Range oncePrefix ) // TailscaleServiceIP returns the IPv4 listen address of services @@ -73,37 +75,58 @@ const ( CoderServiceIPv6String = "fd60:627a:a42b::53" ) -// These are all unfortunately Coder IP ranges, not Tailscale IP ranges. - // IsTailscaleIP reports whether ip is an IP address in a range that -// Coder assigns from. +// Tailscale assigns from. func IsTailscaleIP(ip netip.Addr) bool { + if ip.Is4() { + return CGNATRange().Contains(ip) && !ChromeOSVMRange().Contains(ip) + } return TailscaleULARange().Contains(ip) } +// IsCoderIP reports whether ip is an IP address in the Coder IPv6 range. +func IsCoderIP(ip netip.Addr) bool { + return CoderV6Range().Contains(ip) +} + // TailscaleULARange returns the IPv6 Unique Local Address range that -// is the superset range that Coder assigns out of. +// is the superset range that Tailscale assigns out of. func TailscaleULARange() netip.Prefix { - tsUlaRange.Do(func() { mustPrefix(&tsUlaRange.v, "fd60:627a:a42b::/48") }) + tsUlaRange.Do(func() { mustPrefix(&tsUlaRange.v, "fd7a:115c:a1e0::/48") }) return tsUlaRange.v } -// Unused by Coder +func CoderV6Range() netip.Prefix { + coderV6Range.Do(func() { mustPrefix(&coderV6Range.v, "fd60:627a:a42b::/48") }) + return coderV6Range.v +} + +// TailscaleViaRange returns the IPv6 Unique Local Address subset range +// TailscaleULARange that's used for IPv4 tunneling via IPv6. func TailscaleViaRange() netip.Prefix { - tsViaRange.Do(func() { mustPrefix(&tsViaRange.v, "fd60:627a:a42b::/128") }) + // Mnemonic: "b1a" sounds like "via". + tsViaRange.Do(func() { mustPrefix(&tsViaRange.v, "fd7a:115c:a1e0:b1a::/64") }) return tsViaRange.v } -// Unused by Coder +// Tailscale4To6Range returns the subset of TailscaleULARange used for +// auto-translated Tailscale ipv4 addresses. func Tailscale4To6Range() netip.Prefix { - // This needs to be a /104 prefix, so it can fit IPv4 addresses - ula4To6Range.Do(func() { mustPrefix(&ula4To6Range.v, "fd60:627a:a42b::/104") }) + // This IP range has no significance, beyond being a subset of + // TailscaleULARange. The bits from /48 to /104 were picked at + // random. + ula4To6Range.Do(func() { mustPrefix(&ula4To6Range.v, "fd7a:115c:a1e0:ab12:4843:cd96:6200::/104") }) return ula4To6Range.v } -// Unused by Coder +// TailscaleEphemeral6Range returns the subset of TailscaleULARange +// used for ephemeral IPv6-only Tailscale nodes. func TailscaleEphemeral6Range() netip.Prefix { - ulaEph6Range.Do(func() { mustPrefix(&ulaEph6Range.v, "fd60:627a:a42b::/128") }) + // This IP range has no significance, beyond being a subset of + // TailscaleULARange. The bits from /48 to /64 were picked at + // random, with the only criterion being to not be the conflict + // with the Tailscale4To6Range above. + ulaEph6Range.Do(func() { mustPrefix(&ulaEph6Range.v, "fd7a:115c:a1e0:efe3::/64") }) return ulaEph6Range.v } diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index 7053e811aebe3..154d7e4e7ac3b 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -358,6 +358,8 @@ func TestLoopbackLocalAPI(t *testing.T) { } func TestLoopbackSOCKS5(t *testing.T) { + t.Skip("Coder: The fake control server does not work after Coder's address changes") + flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/8198") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/tstest/integration/integration_test.go b/tstest/integration/integration_test.go index 486b894921acd..206082cfb3b8d 100644 --- a/tstest/integration/integration_test.go +++ b/tstest/integration/integration_test.go @@ -507,6 +507,8 @@ func TestOneNodeUpWindowsStyle(t *testing.T) { // TestNATPing creates two nodes, n1 and n2, sets up masquerades for both and // tries to do bi-directional pings between them. func TestNATPing(t *testing.T) { + t.Skip("Coder: The fake control server does not work after Coder's address changes") + t.Parallel() env := newTestEnv(t) registerNode := func() (*testNode, key.NodePublic) { diff --git a/wgengine/magicsock/endpoint.go b/wgengine/magicsock/endpoint.go index ce8dab8cabccd..c07b390626a3d 100644 --- a/wgengine/magicsock/endpoint.go +++ b/wgengine/magicsock/endpoint.go @@ -1065,7 +1065,7 @@ func (de *endpoint) handleCallMeMaybe(m *disco.CallMeMaybe) { } var newEPs []netip.AddrPort for _, ep := range m.MyNumber { - if (ep.Addr().Is6() && ep.Addr().IsLinkLocalUnicast()) || tsaddr.IsTailscaleIP(ep.Addr()) { + if (ep.Addr().Is6() && ep.Addr().IsLinkLocalUnicast()) || tsaddr.IsCoderIP(ep.Addr()) { // We send these out, but ignore them for now. // TODO: teach the ping code to ping on all interfaces // for these. diff --git a/wgengine/magicsock/magicsock_test.go b/wgengine/magicsock/magicsock_test.go index 02fc474125a9f..22940f8430c3f 100644 --- a/wgengine/magicsock/magicsock_test.go +++ b/wgengine/magicsock/magicsock_test.go @@ -2287,6 +2287,8 @@ func TestIsWireGuardOnlyPeer(t *testing.T) { } func TestIsWireGuardOnlyPeerWithMasquerade(t *testing.T) { + t.Skip("Coder: We do not support wireguard only peers, and this test fails because we currently only support IPv6 addresses for TS IPs") + derpMap, cleanup := runDERPAndStun(t, t.Logf, localhostListener{}, netaddr.IPv4(127, 0, 0, 1)) defer cleanup() diff --git a/wgengine/netlog/logger.go b/wgengine/netlog/logger.go index 3dd02afb9617e..43ccaec9240eb 100644 --- a/wgengine/netlog/logger.go +++ b/wgengine/netlog/logger.go @@ -172,7 +172,7 @@ func recordStatistics(logger *logtail.Logger, nodeID tailcfg.StableNodeID, start break } } - return withinRoute && tsaddr.IsTailscaleIP(a), withinRoute && !tsaddr.IsTailscaleIP(a) + return withinRoute && tsaddr.IsCoderIP(a), withinRoute && !tsaddr.IsCoderIP(a) } exitTraffic := make(map[netlogtype.Connection]netlogtype.Counts) diff --git a/wgengine/netstack/netstack.go b/wgengine/netstack/netstack.go index db060cc0ea4ba..d17d9b7ccb7fe 100644 --- a/wgengine/netstack/netstack.go +++ b/wgengine/netstack/netstack.go @@ -830,7 +830,7 @@ func (ns *Impl) shouldHandlePing(p *packet.Parsed) (_ netip.Addr, ok bool) { // For non-4via6 addresses, we don't handle pings if they're destined // for a Tailscale IP. - if tsaddr.IsTailscaleIP(destIP) { + if tsaddr.IsCoderIP(destIP) { return netip.Addr{}, false } diff --git a/wgengine/netstack/netstack_test.go b/wgengine/netstack/netstack_test.go index f08308e2dad69..36dda59addc78 100644 --- a/wgengine/netstack/netstack_test.go +++ b/wgengine/netstack/netstack_test.go @@ -185,8 +185,8 @@ func TestShouldHandlePing(t *testing.T) { } }) - t.Run("ICMP6-tailscale-addr", func(t *testing.T) { - dst := netip.MustParseAddr("fd7a:115c:a1e0:ab12::1") + t.Run("ICMP6-coder-addr", func(t *testing.T) { + dst := netip.MustParseAddr("fd60:627a:a42b::1") icmph := packet.ICMP6Header{ IP6Header: packet.IP6Header{ IPProto: ipproto.ICMPv6, diff --git a/wgengine/userspace.go b/wgengine/userspace.go index 6c9ec3768c6f2..64f948c9ba89e 100644 --- a/wgengine/userspace.go +++ b/wgengine/userspace.go @@ -1459,13 +1459,13 @@ func (e *userspaceEngine) PeerForIP(ip netip.Addr) (ret PeerForIP, ok bool) { // TODO(bradfitz): add maps for these. on NetworkMap? for _, p := range nm.Peers { for _, a := range p.Addresses { - if a.Addr() == ip && a.IsSingleIP() && tsaddr.IsTailscaleIP(ip) { + if a.Addr() == ip && a.IsSingleIP() && tsaddr.IsCoderIP(ip) { return PeerForIP{Node: p, Route: a}, true } } } for _, a := range nm.Addresses { - if a.Addr() == ip && a.IsSingleIP() && tsaddr.IsTailscaleIP(ip) { + if a.Addr() == ip && a.IsSingleIP() && tsaddr.IsCoderIP(ip) { return PeerForIP{Node: nm.SelfNode, IsSelf: true, Route: a}, true } } diff --git a/wgengine/wgcfg/nmcfg/nmcfg.go b/wgengine/wgcfg/nmcfg/nmcfg.go index f01b42cb1e293..c7d5b2c1f6904 100644 --- a/wgengine/wgcfg/nmcfg/nmcfg.go +++ b/wgengine/wgcfg/nmcfg/nmcfg.go @@ -114,7 +114,7 @@ func WGCfg(nm *netmap.NetworkMap, logf logger.Logf, flags netmap.WGConfigFlags, } fmt.Fprintf(skippedUnselected, "%q (%v)", nodeDebugName(peer), peer.Key.ShortString()) continue - } else if allowedIP.IsSingleIP() && tsaddr.IsTailscaleIP(allowedIP.Addr()) && (flags&netmap.AllowSingleHosts) == 0 { + } else if allowedIP.IsSingleIP() && tsaddr.IsCoderIP(allowedIP.Addr()) && (flags&netmap.AllowSingleHosts) == 0 { if skippedIPs.Len() > 0 { skippedIPs.WriteString(", ") } From 0d1c63c2fa3f9cc58f8290d5fbfd854660738623 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 23 Jul 2025 09:46:36 +0000 Subject: [PATCH 4/6] Add tests --- net/netns/netns_windows.go | 130 +++++++++++++++++++------------- net/netns/netns_windows_test.go | 97 ++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 51 deletions(-) create mode 100644 net/netns/netns_windows_test.go diff --git a/net/netns/netns_windows.go b/net/netns/netns_windows.go index 1d889b63fe8ad..dd733542fb56f 100644 --- a/net/netns/netns_windows.go +++ b/net/netns/netns_windows.go @@ -31,6 +31,17 @@ func interfaceIndex(iface *winipcfg.IPAdapterAddresses) uint32 { return iface.IfIndex } +// getBestInterface can be swapped out in tests. +var getBestInterface func(addr windows.Sockaddr, idx *uint32) error = windows.GetBestInterfaceEx + +// isInterfaceCoderInterface can be swapped out in tests. +var isInterfaceCoderInterface func(int) bool = isInterfaceCoderInterfaceDefault + +func isInterfaceCoderInterfaceDefault(idx int) bool { + _, tsif, err := interfaces.Coder() + return err == nil && tsif != nil && tsif.Index == idx +} + func control(logf logger.Logf, netMon *netmon.Monitor) func(network, address string, c syscall.RawConn) error { return func(network, address string, c syscall.RawConn) error { return controlLogf(logf, netMon, network, address, c) @@ -40,13 +51,10 @@ func control(logf logger.Logf, netMon *netmon.Monitor) func(network, address str // controlC binds c to the Windows interface that holds a default // route, and is not the Tailscale WinTun interface. func controlLogf(logf logger.Logf, _ *netmon.Monitor, network, address string, c syscall.RawConn) error { - if strings.HasPrefix(address, "127.") { - // Don't bind to an interface for localhost connections, - // otherwise we get: - // connectex: The requested address is not valid in its context - // (The derphttp tests were failing) + if !shouldBindToDefaultInterface(logf, address) { return nil } + canV4, canV6 := false, false switch network { case "tcp", "udp": @@ -57,42 +65,6 @@ func controlLogf(logf logger.Logf, _ *netmon.Monitor, network, address string, c canV6 = true } - if coderSoftIsolation.Load() { - sockAddr, err := getSockAddr(address) - if err != nil { - logf("netns: Coder soft isolation: error getting sockaddr: %v", err) - return err - } - if sockAddr == nil { - // Sockets bound like :0 or :1234 cannot be checked. - return nil - } - - // Ask Windows to find the best interface for this address by consulting - // the routing table. - // - // On macOS this value gets cached, but on Windows we don't need to - // because this API is very fast and doesn't require opening an AF_ROUTE - // socket. - var idx uint32 - err = windows.GetBestInterfaceEx(sockAddr, &idx) - if err != nil { - logf("netns: Coder soft isolation: error getting best interface: %v", err) - return err - } - - _, tsif, err2 := interfaces.Coder() - if err2 == nil && tsif != nil && tsif.Index == int(idx) { - logf("[unexpected] netns: Coder soft isolation: detected socket bound to Coder interface") - // No return, we want to run the code below to bind this socket to - // the default interface. - } else { - // It doesn't look like our own interface, so we return early to - // prevent the socket from being bound to the default interface. - return nil - } - } - if canV4 { iface, err := interfaces.GetWindowsDefault(windows.AF_INET) if err != nil { @@ -116,6 +88,54 @@ func controlLogf(logf logger.Logf, _ *netmon.Monitor, network, address string, c return nil } +func shouldBindToDefaultInterface(logf logger.Logf, address string) bool { + if strings.HasPrefix(address, "127.") { + // Don't bind to an interface for localhost connections, + // otherwise we get: + // connectex: The requested address is not valid in its context + // (The derphttp tests were failing) + return false + } + + if coderSoftIsolation.Load() { + sockAddr, err := getSockAddr(address) + if err != nil { + logf("[unexpected] netns: Coder soft isolation: error getting sockaddr for %q, binding to default: %v", address, err) + return true + } + if sockAddr == nil { + // Unspecified addresses should not be bound to any interface. + return false + } + + // Ask Windows to find the best interface for this address by consulting + // the routing table. + // + // On macOS this value gets cached, but on Windows we don't need to + // because this API is very fast and doesn't require opening an AF_ROUTE + // socket. + var idx uint32 + err = getBestInterface(sockAddr, &idx) + if err != nil { + logf("[unexpected] netns: Coder soft isolation: error getting best interface, binding to default: %v", err) + return true + } + + if isInterfaceCoderInterface(int(idx)) { + logf("[unexpected] netns: Coder soft isolation: detected socket destined for Coder interface, binding to default") + return true + } + + // It doesn't look like our own interface, so we don't need to bind the + // socket to the default interface. + return false + } + + // The default isolation behavior is to always bind to the default + // interface. + return true +} + // sockoptBoundInterface is the value of IP_UNICAST_IF and IPV6_UNICAST_IF. // // See https://docs.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options @@ -175,29 +195,37 @@ func getSockAddr(address string) (windows.Sockaddr, error) { return nil, fmt.Errorf("invalid address %q: %w", address, err) } if host == "" { - // Sockets bound like :0 or :1234 cannot be checked. + // netip.ParseAddr("") will fail return nil, nil } - portInt, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("invalid port %q: %w", port, err) - } + addr, err := netip.ParseAddr(host) if err != nil { return nil, fmt.Errorf("invalid address %q: %w", address, err) } + if addr.Zone() != "" { + // Addresses with zones *can* be represented as a Sockaddr with extra + // effort, but we don't use or support them currently. + return nil, fmt.Errorf("invalid address %q, has zone: %w", address, err) + } + if addr.IsUnspecified() { + // This covers the cases of 0.0.0.0 and [::]. + return nil, nil + } + + portInt, err := strconv.ParseUint(port, 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port %q: %w", port, err) + } if addr.Is4() { return &windows.SockaddrInet4{ - Port: portInt, + Port: int(portInt), // nolint:gosec // portInt is always in range Addr: addr.As4(), }, nil } else if addr.Is6() { - if addr.Zone() != "" { - return nil, fmt.Errorf("invalid address %q, has zone: %w", address, err) - } return &windows.SockaddrInet6{ - Port: portInt, + Port: int(portInt), // nolint:gosec // portInt is always in range Addr: addr.As16(), }, nil } diff --git a/net/netns/netns_windows_test.go b/net/netns/netns_windows_test.go new file mode 100644 index 0000000000000..f11fb0a2861a4 --- /dev/null +++ b/net/netns/netns_windows_test.go @@ -0,0 +1,97 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package netns + +import ( + "strconv" + "testing" + + "golang.org/x/sys/windows" +) + +func TestShouldBindToDefaultInterface(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + tests := []struct { + address string + want bool + }{ + {"127.0.0.1:0", false}, + {"127.0.0.1:1234", false}, + {"1.2.3.4:0", true}, + {"1.2.3.4:1234", true}, + } + + for _, test := range tests { + t.Run(test.address, func(t *testing.T) { + got := shouldBindToDefaultInterface(t.Logf, test.address) + if got != test.want { + t.Errorf("want %v, got %v", test.want, got) + } + }) + } + }) + + t.Run("CoderSoftIsolation", func(t *testing.T) { + SetCoderSoftIsolation(true) + getBestInterface = func(addr windows.Sockaddr, idx *uint32) error { + *idx = 1 + return nil + } + t.Cleanup(func() { + SetCoderSoftIsolation(false) + getBestInterface = windows.GetBestInterfaceEx + }) + + tests := []struct { + address string + isCoderInterface bool + want bool + }{ + // isCoderInterface shouldn't even matter for localhost since it has + // a special exemption. + {"127.0.0.1:0", false, false}, + {"127.0.0.1:0", true, false}, + {"127.0.0.1:1234", false, false}, + {"127.0.0.1:1234", true, false}, + + {"1.2.3.4:0", false, false}, + {"1.2.3.4:0", true, true}, + {"1.2.3.4:1234", false, false}, + {"1.2.3.4:1234", true, true}, + + // Unspecified addresses should not be bound to any interface. + {":1234", false, false}, + {":1234", true, false}, + {"0.0.0.0:1234", false, false}, + {"0.0.0.0:1234", true, false}, + {"[::]:1234", false, false}, + {"[::]:1234", true, false}, + + // Special cases should always bind to default: + {"[::%eth0]:1234", false, true}, // zones are not supported + {"1.2.3.4:", false, true}, // port is empty + {"1.2.3.4:a", false, true}, // port is not a number + {"1.2.3.4:-1", false, true}, // port is negative + {"1.2.3.4:65536", false, true}, // port is too large + + } + + for _, test := range tests { + name := test.address + " (isCoderInterface=" + strconv.FormatBool(test.isCoderInterface) + ")" + t.Run(name, func(t *testing.T) { + isInterfaceCoderInterface = func(_ int) bool { + return test.isCoderInterface + } + defer func() { + isInterfaceCoderInterface = isInterfaceCoderInterfaceDefault + }() + + got := shouldBindToDefaultInterface(t.Logf, test.address) + if got != test.want { + t.Errorf("want %v, got %v", test.want, got) + } + }) + } + }) +} From 76c313c9fb49244da14a995616db87d11d1e8f8e Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 23 Jul 2025 13:31:30 +0000 Subject: [PATCH 5/6] comments --- net/interfaces/interfaces.go | 6 +++--- net/netns/netns.go | 3 ++- wgengine/magicsock/endpoint.go | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/net/interfaces/interfaces.go b/net/interfaces/interfaces.go index 70b4e6bf4b3eb..7ab46b93a99ea 100644 --- a/net/interfaces/interfaces.go +++ b/net/interfaces/interfaces.go @@ -33,7 +33,7 @@ func Coder() ([]netip.Addr, *net.Interface, error) { return nil, nil, err } for _, iface := range ifs { - if !maybeTailscaleInterfaceName(iface.Name) { + if !maybeCoderInterfaceName(iface.Name) { continue } addrs, err := iface.Addrs() @@ -57,9 +57,9 @@ func Coder() ([]netip.Addr, *net.Interface, error) { return nil, nil, nil } -// maybeTailscaleInterfaceName reports whether s is an interface +// maybeCoderInterfaceName reports whether s is an interface // name that might be used by Coder. -func maybeTailscaleInterfaceName(s string) bool { +func maybeCoderInterfaceName(s string) bool { return s == "Coder" || strings.HasPrefix(s, "coder") || strings.HasPrefix(s, "utun") diff --git a/net/netns/netns.go b/net/netns/netns.go index 0caa304a82fee..a5bbcf95229a2 100644 --- a/net/netns/netns.go +++ b/net/netns/netns.go @@ -71,7 +71,8 @@ var coderSoftIsolation atomic.Bool // a single interface, which causes problems with direct connections in // magicsock. // -// This currently only has an effect on Windows and macOS. +// This currently only has an effect on Windows and macOS, and is only used by +// Coder Connect. func SetCoderSoftIsolation(v bool) { coderSoftIsolation.Store(v) } diff --git a/wgengine/magicsock/endpoint.go b/wgengine/magicsock/endpoint.go index c07b390626a3d..3bdd8c206ae5c 100644 --- a/wgengine/magicsock/endpoint.go +++ b/wgengine/magicsock/endpoint.go @@ -1066,7 +1066,8 @@ func (de *endpoint) handleCallMeMaybe(m *disco.CallMeMaybe) { var newEPs []netip.AddrPort for _, ep := range m.MyNumber { if (ep.Addr().Is6() && ep.Addr().IsLinkLocalUnicast()) || tsaddr.IsCoderIP(ep.Addr()) { - // We send these out, but ignore them for now. + // We potentially send ULAs and Coder IPs out, but we want to ignore + // them for now. // TODO: teach the ping code to ping on all interfaces // for these. continue From fb40653d2b4efa15320dc512982e55970b7a6ecc Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 24 Jul 2025 01:31:02 +0000 Subject: [PATCH 6/6] fix unrelated test --- net/netns/netns.go | 9 +++++ wgengine/magicsock/magicsock_test.go | 51 ++++++++++++++++------------ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/net/netns/netns.go b/net/netns/netns.go index a5bbcf95229a2..840b90fecdc9e 100644 --- a/net/netns/netns.go +++ b/net/netns/netns.go @@ -59,6 +59,10 @@ var coderSoftIsolation atomic.Bool // functionality. All other network isolation settings are ignored when this is // set. // +// Soft isolation is a workaround for allowing Coder Connect to function with +// corporate VPNs. Without this, Coder Connect cannot connect to Coder +// deployments behind corporate VPNs. +// // Soft isolation does the following: // 1. Determine the interface that will be used for a given destination IP by // consulting the OS. @@ -71,6 +75,11 @@ var coderSoftIsolation atomic.Bool // a single interface, which causes problems with direct connections in // magicsock. // +// Enabling this has the risk of potential network loops, as sockets could race +// changes to the OS routing table or interface list. Coder doesn't provide +// functionality similar to Tailscale's Exit Nodes, so we don't expect loops +// to occur in our use case. +// // This currently only has an effect on Windows and macOS, and is only used by // Coder Connect. func SetCoderSoftIsolation(v bool) { diff --git a/wgengine/magicsock/magicsock_test.go b/wgengine/magicsock/magicsock_test.go index 22940f8430c3f..e7786404ded73 100644 --- a/wgengine/magicsock/magicsock_test.go +++ b/wgengine/magicsock/magicsock_test.go @@ -3136,40 +3136,47 @@ func TestBlockEndpointsDERPOK(t *testing.T) { } } +func getNonDERPEndpoints(ms *Conn) []tailcfg.Endpoint { + ms.mu.Lock() + defer ms.mu.Unlock() + nonDERPEndpoints := make([]tailcfg.Endpoint, 0, len(ms.lastEndpoints)) + for _, ep := range ms.lastEndpoints { + if ep.Addr.Addr() != tailcfg.DerpMagicIPAddr { + nonDERPEndpoints = append(nonDERPEndpoints, ep) + } + } + return nonDERPEndpoints +} + func waitForNoEndpoints(t *testing.T, ms *Conn) { t.Helper() - ok := false - for i := 0; i < 50; i++ { + + t.Log("waiting for endpoints to be blocked") + for range 50 { time.Sleep(100 * time.Millisecond) - ms.mu.Lock() - if len(ms.lastEndpoints) != 0 { - t.Errorf("some endpoints were not blocked: %v", ms.lastEndpoints) - ms.mu.Unlock() + nonDERPEndpoints := getNonDERPEndpoints(ms) + if len(nonDERPEndpoints) != 0 { + t.Logf("some non-DERP endpoints were not blocked yet: %v", nonDERPEndpoints) continue } - ms.mu.Unlock() - ok = true - break - } - if !ok { - t.Fatal("endpoints were not blocked after 50 attempts") + + t.Log("endpoints are blocked") + return } - t.Log("endpoints are blocked") + t.Fatal("endpoints were not blocked after 50 attempts") } func waitForEndpoints(t *testing.T, ms *Conn) { t.Helper() - for i := 0; i < 50; i++ { + + t.Log("waiting for endpoints to be found") + for range 50 { time.Sleep(100 * time.Millisecond) - ms.mu.Lock() - for _, ep := range ms.lastEndpoints { - if ep.Addr.Addr() != tailcfg.DerpMagicIPAddr { - t.Log("endpoint found") - ms.mu.Unlock() - return - } + nonDERPEndpoints := getNonDERPEndpoints(ms) + if len(nonDERPEndpoints) > 0 { + t.Logf("non-DERP endpoints found: %v", nonDERPEndpoints) + return } - ms.mu.Unlock() } t.Fatal("endpoint was not found after 50 attempts") }