Skip to content

fix: add soft isolation mode and windows impl #88

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions control/controlbase/conn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did we increase the limit for the test?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was using 2018 bytes per run, no idea why

if growthEach > max {
t.Errorf("allocated more than expected; want max %v bytes/each", max)
}
Expand Down
2 changes: 1 addition & 1 deletion net/dns/nm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
28 changes: 13 additions & 15 deletions net/interfaces/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ import (
// which HTTP proxy the system should use.
var LoginEndpointForProxyDetermination = "https://controlplane.tailscale.com/"

// Tailscale returns the current machine's Tailscale 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
}
for _, iface := range ifs {
if !maybeTailscaleInterfaceName(iface.Name) {
if !maybeCoderInterfaceName(iface.Name) {
continue
}
addrs, err := iface.Addrs()
Expand All @@ -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)
}
}
Expand All @@ -57,13 +57,11 @@ func Tailscale() ([]netip.Addr, *net.Interface, error) {
return nil, nil, nil
}

// maybeTailscaleInterfaceName reports whether s is an interface
// name that might be used by Tailscale.
func maybeTailscaleInterfaceName(s string) bool {
return s == "Tailscale" ||
strings.HasPrefix(s, "wg") ||
strings.HasPrefix(s, "ts") ||
strings.HasPrefix(s, "tailscale") ||
// maybeCoderInterfaceName reports whether s is an interface
// name that might be used by Coder.
func maybeCoderInterfaceName(s string) bool {
return s == "Coder" ||
strings.HasPrefix(s, "coder") ||
strings.HasPrefix(s, "utun")
}

Expand Down Expand Up @@ -120,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 {
Expand Down Expand Up @@ -479,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
}
}
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions net/netmon/netmon_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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.
Expand All @@ -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{}
Expand Down
4 changes: 2 additions & 2 deletions net/netmon/netmon_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions net/netns/netns.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ 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 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.
// 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.
//
// 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) {
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.
Expand Down
6 changes: 3 additions & 3 deletions net/netns/netns_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
2 changes: 1 addition & 1 deletion net/netns/netns_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
123 changes: 115 additions & 8 deletions net/netns/netns_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
package netns

import (
"fmt"
"math/bits"
"net"
"net/netip"
"strconv"
"strings"
"syscall"

Expand All @@ -27,20 +31,30 @@ 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
// 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)
}
}

// 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 {
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)
func controlLogf(logf logger.Logf, _ *netmon.Monitor, network, address string, c syscall.RawConn) error {
if !shouldBindToDefaultInterface(logf, address) {
return nil
}

canV4, canV6 := false, false
switch network {
case "tcp", "udp":
Expand Down Expand Up @@ -74,6 +88,54 @@ func controlC(network, address string, c syscall.RawConn) error {
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
Expand Down Expand Up @@ -124,3 +186,48 @@ 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 == "" {
// netip.ParseAddr("") will fail
return nil, nil
}

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: int(portInt), // nolint:gosec // portInt is always in range
Addr: addr.As4(),
}, nil
} else if addr.Is6() {
return &windows.SockaddrInet6{
Port: int(portInt), // nolint:gosec // portInt is always in range
Addr: addr.As16(),
}, nil
}
return nil, fmt.Errorf("invalid address %q, is not IPv4 or IPv6: %w", address, err)
}
Loading
Loading