tailscale/cmd/tta/bypass_linux.go
Brad Fitzpatrick 5c1738fd56 tstest/natlab/{vmtest,vnet}, cmd/tta: add TestExitNode
Add a vmtest TestExitNode that brings up a client, two exit nodes, and a
non-Tailscale webserver, each on its own NAT'd vnet network with a
distinct WAN IP. The test cycles the client's exit node setting between
off, exit1, and exit2 and asserts that the webserver echoes the expected
post-NAT source IP for each.

Three pieces were needed to make this work:

vnet now forwards TCP between simulated networks at the packet level,
mirroring the existing UDP path. When a guest VM sends TCP to another
simulated network's WAN IP, the source network's gateway rewrites src
via doNATOut and routeTCPPacket hands the packet off to the destination
network, which rewrites dst via doNATIn and writes the rewritten frame
onto the destination LAN. The TCP stacks of the two guest VM kernels
talk end-to-end; vnet just NATs the IP/port headers in flight, so all
TCP semantics (handshakes, options, sequence numbers, payload) are
preserved without a gvisor TCP termination in the middle. Adds a
focused TestInterNetworkTCP that exercises this path without any
Tailscale machinery.

cmd/tta binds its outbound dial to the default route's interface using
SO_BINDTODEVICE. Without that, the moment tailscaled installs
0.0.0.0/0 → tailscale0 in response to setting an exit node, TTA's
existing TCP connection to test-driver gets rerouted through the exit
node. From the test driver's perspective the connection's packets then
arrive with the exit node's WAN IP as the source rather than the
client's, so they don't match the existing flow and the connection is
dead — manifesting in the test as a hang on EditPrefs (which had
actually completed in milliseconds on the daemon side, but whose
response never made it back). Pinning the socket to the underlying NIC
keeps TTA's agent connection on a real interface regardless of any
policy routing tailscaled installs later. We bind rather than carry the
Tailscale bypass fwmark because the fwmark approach is conditional on
tailscaled having configured SO_MARK-based policy routing, while
binding is unconditional.

vmtest grows an Env.SetExitNode helper that sets ExitNodeIP via
EditPrefs through the agent, used by the new test.

Updates #13038

Change-Id: I9fc8f91848b7aa2297ef3eaf71fed9d96056a024
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-27 16:54:20 -07:00

40 lines
1.3 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"syscall"
"golang.org/x/sys/unix"
"tailscale.com/net/netmon"
)
// bypassControlFunc is set as net.Dialer.Control so that sockets dialed by
// TTA bypass tailscaled's policy routing. Without it, sockets opened before
// tailscaled installs an exit-node route would have their packets rerouted
// via the exit node when the route is later installed, breaking the
// existing connection.
//
// We bind the socket to the default route's interface (typically the VM's
// LAN-facing NIC) rather than relying on the bypass fwmark. The fwmark
// approach is conditional on tailscaled having configured SO_MARK-based
// policy routing; binding to the underlying interface is unconditional.
func bypassControlFunc(network, address string, c syscall.RawConn) error {
ifc, err := netmon.DefaultRouteInterface()
if err != nil {
return fmt.Errorf("netmon.DefaultRouteInterface: %w", err)
}
var sockErr error
if err := c.Control(func(fd uintptr) {
sockErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, ifc)
}); err != nil {
return err
}
if sockErr != nil {
return fmt.Errorf("setting SO_BINDTODEVICE on %q: %w", ifc, sockErr)
}
return nil
}