Skip to content

chore: improve tailnet integration test #18124

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
Jun 6, 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
219 changes: 154 additions & 65 deletions tailnet/test/integration/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import (
"golang.org/x/xerrors"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/packet"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/wgengine/capture"

"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/httpapi"
Expand All @@ -54,35 +56,36 @@ type Client struct {
ID uuid.UUID
ListenPort uint16
ShouldRunTests bool
TunnelSrc bool
}

var Client1 = Client{
Number: ClientNumber1,
ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
ListenPort: client1Port,
ShouldRunTests: true,
TunnelSrc: true,
}

var Client2 = Client{
Number: ClientNumber2,
ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
ListenPort: client2Port,
ShouldRunTests: false,
TunnelSrc: false,
}

type TestTopology struct {
Name string
// SetupNetworking creates interfaces and network namespaces for the test.
// The most simple implementation is NetworkSetupDefault, which only creates
// a network namespace shared for all tests.
SetupNetworking func(t *testing.T, logger slog.Logger) TestNetworking

NetworkingProvider NetworkingProvider

// Server is the server starter for the test. It is executed in the server
// subprocess.
Server ServerStarter
// StartClient gets called in each client subprocess. It's expected to
// ClientStarter.StartClient gets called in each client subprocess. It's expected to
// create the tailnet.Conn and ensure connectivity to it's peer.
StartClient func(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me Client, peer Client) *tailnet.Conn
ClientStarter ClientStarter

// RunTests is the main test function. It's called in each of the client
// subprocesses. If tests can only run once, they should check the client ID
Expand All @@ -97,6 +100,17 @@ type ServerStarter interface {
StartServer(t *testing.T, logger slog.Logger, listenAddr string)
}

type NetworkingProvider interface {
// SetupNetworking creates interfaces and network namespaces for the test.
// The most simple implementation is NetworkSetupDefault, which only creates
// a network namespace shared for all tests.
SetupNetworking(t *testing.T, logger slog.Logger) TestNetworking
}

type ClientStarter interface {
StartClient(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me Client, peer Client) *tailnet.Conn
}

type SimpleServerOptions struct {
// FailUpgradeDERP will make the DERP server fail to handle the initial DERP
// upgrade in a way that causes the client to fallback to
Expand Down Expand Up @@ -369,77 +383,107 @@ http {
_, _ = ExecBackground(t, "server.nginx", nil, "nginx", []string{"-c", cfgPath})
}

// StartClientDERP creates a client connection to the server for coordination
// and creates a tailnet.Conn which will only use DERP to connect to the peer.
func StartClientDERP(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn {
return startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{
Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)},
DERPMap: derpMap,
BlockEndpoints: true,
Logger: logger,
DERPForceWebSockets: false,
ListenPort: me.ListenPort,
// These tests don't have internet connection, so we need to force
// magicsock to do anything.
ForceNetworkUp: true,
})
type BasicClientStarter struct {
BlockEndpoints bool
DERPForceWebsockets bool
// WaitForConnection means wait for (any) peer connection before returning from StartClient
WaitForConnection bool
// WaitForConnection means wait for a direct peer connection before returning from StartClient
WaitForDirect bool
// Service is a network service (e.g. an echo server) to start on the client. If Wait* is set, the service is
// started prior to waiting.
Service NetworkService
LogPackets bool
}

// StartClientDERPWebSockets does the same thing as StartClientDERP but will
// only use DERP WebSocket fallback.
func StartClientDERPWebSockets(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn {
return startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{
Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)},
DERPMap: derpMap,
BlockEndpoints: true,
Logger: logger,
DERPForceWebSockets: true,
ListenPort: me.ListenPort,
// These tests don't have internet connection, so we need to force
// magicsock to do anything.
ForceNetworkUp: true,
})
type NetworkService interface {
StartService(t *testing.T, logger slog.Logger, conn *tailnet.Conn)
}

// StartClientDirect does the same thing as StartClientDERP but disables
// BlockEndpoints (which enables Direct connections), and waits for a direct
// connection to be established between the two peers.
func StartClientDirect(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn {
func (b BasicClientStarter) StartClient(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn {
var hook capture.Callback
if b.LogPackets {
pktLogger := packetLogger{logger}
hook = pktLogger.LogPacket
}
conn := startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{
Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)},
DERPMap: derpMap,
BlockEndpoints: false,
BlockEndpoints: b.BlockEndpoints,
Logger: logger,
DERPForceWebSockets: true,
DERPForceWebSockets: b.DERPForceWebsockets,
ListenPort: me.ListenPort,
// These tests don't have internet connection, so we need to force
// magicsock to do anything.
ForceNetworkUp: true,
CaptureHook: hook,
})

// Wait for direct connection to be established.
peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID)
require.Eventually(t, func() bool {
t.Log("attempting ping to peer to judge direct connection")
ctx := testutil.Context(t, testutil.WaitShort)
_, p2p, pong, err := conn.Ping(ctx, peerIP)
if err != nil {
t.Logf("ping failed: %v", err)
return false
}
if !p2p {
t.Log("ping succeeded, but not direct yet")
return false
}
t.Logf("ping succeeded, direct connection established via %s", pong.Endpoint)
return true
}, testutil.WaitLong, testutil.IntervalMedium)
if b.Service != nil {
b.Service.StartService(t, logger, conn)
}

if b.WaitForConnection || b.WaitForDirect {
// Wait for connection to be established.
peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID)
require.Eventually(t, func() bool {
t.Log("attempting ping to peer to judge direct connection")
ctx := testutil.Context(t, testutil.WaitShort)
_, p2p, pong, err := conn.Ping(ctx, peerIP)
if err != nil {
t.Logf("ping failed: %v", err)
return false
}
if !p2p && b.WaitForDirect {
t.Log("ping succeeded, but not direct yet")
return false
}
t.Logf("ping succeeded, p2p=%t, endpoint=%s", p2p, pong.Endpoint)
return true
}, testutil.WaitLong, testutil.IntervalMedium)
}

return conn
}

type ClientStarter struct {
Options *tailnet.Options
const EchoPort = 2381

type UDPEchoService struct{}

func (UDPEchoService) StartService(t *testing.T, logger slog.Logger, _ *tailnet.Conn) {
// tailnet doesn't handle UDP connections "in-process" the way we do for TCP, so we need to listen in the OS,
// and tailnet will forward packets.
l, err := net.ListenUDP("udp", &net.UDPAddr{
IP: net.IPv6zero, // all interfaces
Port: EchoPort,
})
require.NoError(t, err)
logger.Info(context.Background(), "started UDPEcho server")
t.Cleanup(func() {
lCloseErr := l.Close()
if lCloseErr != nil {
t.Logf("error closing UDPEcho listener: %v", lCloseErr)
}
})
go func() {
buf := make([]byte, 1500)
for {
n, remote, readErr := l.ReadFromUDP(buf)
if readErr != nil {
logger.Info(context.Background(), "error reading UDPEcho listener", slog.Error(readErr))
return
}
logger.Info(context.Background(), "received UDPEcho packet",
slog.F("len", n), slog.F("remote", remote))
n, writeErr := l.WriteToUDP(buf[:n], remote)
if writeErr != nil {
logger.Info(context.Background(), "error writing UDPEcho listener", slog.Error(writeErr))
return
}
logger.Info(context.Background(), "wrote UDPEcho packet",
slog.F("len", n), slog.F("remote", remote))
}
}()
}

func startClientOptions(t *testing.T, logger slog.Logger, serverURL *url.URL, me, peer Client, options *tailnet.Options) *tailnet.Conn {
Expand Down Expand Up @@ -467,9 +511,16 @@ func startClientOptions(t *testing.T, logger slog.Logger, serverURL *url.URL, me
_ = conn.Close()
})

ctrl := tailnet.NewTunnelSrcCoordController(logger, conn)
ctrl.AddDestination(peer.ID)
coordination := ctrl.New(coord)
var coordination tailnet.CloserWaiter
if me.TunnelSrc {
ctrl := tailnet.NewTunnelSrcCoordController(logger, conn)
ctrl.AddDestination(peer.ID)
coordination = ctrl.New(coord)
} else {
// use the "Agent" controller so that we act as a tunnel destination and send "ReadyForHandshake" acks.
ctrl := tailnet.NewAgentCoordinationController(logger, conn)
coordination = ctrl.New(coord)
}
t.Cleanup(func() {
cctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
Expand All @@ -492,11 +543,17 @@ func basicDERPMap(serverURLStr string) (*tailcfg.DERPMap, error) {
}

hostname := serverURL.Hostname()
ipv4 := ""
ipv4 := "none"
ipv6 := "none"
ip, err := netip.ParseAddr(hostname)
if err == nil {
hostname = ""
ipv4 = ip.String()
if ip.Is4() {
ipv4 = ip.String()
}
if ip.Is6() {
ipv6 = ip.String()
}
}

return &tailcfg.DERPMap{
Expand All @@ -511,7 +568,7 @@ func basicDERPMap(serverURLStr string) (*tailcfg.DERPMap, error) {
RegionID: 1,
HostName: hostname,
IPv4: ipv4,
IPv6: "none",
IPv6: ipv6,
DERPPort: port,
STUNPort: -1,
ForceHTTP: true,
Expand Down Expand Up @@ -648,3 +705,35 @@ func (w *testWriter) Flush() {
}
w.capturedLines = nil
}

type packetLogger struct {
l slog.Logger
}

func (p packetLogger) LogPacket(path capture.Path, when time.Time, pkt []byte, _ packet.CaptureMeta) {
q := new(packet.Parsed)
q.Decode(pkt)
p.l.Info(context.Background(), "Packet",
slog.F("path", pathString(path)),
slog.F("when", when),
slog.F("decode", q.String()),
slog.F("len", len(pkt)),
)
}

func pathString(path capture.Path) string {
switch path {
case capture.FromLocal:
return "Local"
case capture.FromPeer:
return "Peer"
case capture.SynthesizedToLocal:
return "SynthesizedToLocal"
case capture.SynthesizedToPeer:
return "SynthesizedToPeer"
case capture.PathDisco:
return "Disco"
default:
return "<<UNKNOWN>>"
}
}
Loading
Loading