Files
tailnet/derpclient_test.go

231 lines
5.8 KiB
Go
Raw Permalink Normal View History

Implement complete Tailscale protocol stack: control, DERP, WireGuard, disco NAT traversal Clean-room implementation of the Tailscale protocol without tsnet/wgengine/netmon dependencies, enabling cross-platform support including Android (no NetlinkRIB). Control plane (control.go): - Noise IK handshake (ts2021) over HTTP/2 - Register, MapLite (non-streaming), MapPoll (streaming) - SendEndpointUpdate (Stream=false, OmitPeers=true) — the only way to push node state to control (streaming MapPoll is read-only for v>=68) Data plane (magicbind.go, derpclient.go): - conn.Bind implementation multiplexing direct UDP and DERP relay - DERP client with per-region connections and recv multiplexing - Close/CloseAll split to survive WireGuard IpcSet rebind cycle NAT traversal (disco.go): - STUN endpoint discovery via tailscale.com/net/stun (zero deps) - CallMeMaybe/Ping/Pong over DERP for hole punching - Direct UDP path establishment with latency tracking - Keepalive re-ping loop (30s) to maintain direct paths - UDP data forwarding from disco socket to MagicBind recv path DERP region selection (derpclient.go): - Parallel HTTPS latency probing of all DERP regions - Lowest-latency region selected as home DERP Orchestration (outbound.go): - Register → MapLite → ProbeRegions → ConnectDERP → SendEndpointUpdate → MapPoll → WireGuard+netstack → Disco+STUN → keepalive - DialContext/ListenPacket/Peers/LookupTailscale API Verified: DERP relay 500ms+ → direct UDP 13ms after disco NAT traversal.
2026-04-02 11:48:15 +08:00
package tailnet
import (
"context"
"os"
"testing"
"time"
"tailscale.com/types/key"
)
// TestDERPConnect verifies we can connect to a DERP relay obtained from MapPoll.
func TestDERPConnect(t *testing.T) {
authKey := os.Getenv("TAILNET_AUTH_KEY")
if authKey == "" {
t.Skip("set TAILNET_AUTH_KEY to run DERP tests")
}
dir := t.TempDir()
c, err := NewControlClient(ControlOptions{
StateDir: dir,
AuthKey: authKey,
Hostname: "derp-test",
Logf: t.Logf,
})
if err != nil {
t.Fatalf("NewControlClient: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Register + MapPoll to get DERPMap
if _, err := c.Register(ctx); err != nil {
t.Fatalf("Register: %v", err)
}
ch, err := c.MapPoll(ctx)
if err != nil {
t.Fatalf("MapPoll: %v", err)
}
var derpMap = c.DERPMap()
// Wait for a MapResponse with DERPMap
for derpMap == nil {
mr, ok := <-ch
if !ok {
t.Fatal("map channel closed without DERPMap")
}
if mr.DERPMap != nil {
derpMap = mr.DERPMap
}
}
t.Logf("got DERP map with %d regions", len(derpMap.Regions))
// Pick first region
var regionID int
for id := range derpMap.Regions {
regionID = id
break
}
dc := NewDERPClient(c.nodeKey, t.Logf, nil)
Implement complete Tailscale protocol stack: control, DERP, WireGuard, disco NAT traversal Clean-room implementation of the Tailscale protocol without tsnet/wgengine/netmon dependencies, enabling cross-platform support including Android (no NetlinkRIB). Control plane (control.go): - Noise IK handshake (ts2021) over HTTP/2 - Register, MapLite (non-streaming), MapPoll (streaming) - SendEndpointUpdate (Stream=false, OmitPeers=true) — the only way to push node state to control (streaming MapPoll is read-only for v>=68) Data plane (magicbind.go, derpclient.go): - conn.Bind implementation multiplexing direct UDP and DERP relay - DERP client with per-region connections and recv multiplexing - Close/CloseAll split to survive WireGuard IpcSet rebind cycle NAT traversal (disco.go): - STUN endpoint discovery via tailscale.com/net/stun (zero deps) - CallMeMaybe/Ping/Pong over DERP for hole punching - Direct UDP path establishment with latency tracking - Keepalive re-ping loop (30s) to maintain direct paths - UDP data forwarding from disco socket to MagicBind recv path DERP region selection (derpclient.go): - Parallel HTTPS latency probing of all DERP regions - Lowest-latency region selected as home DERP Orchestration (outbound.go): - Register → MapLite → ProbeRegions → ConnectDERP → SendEndpointUpdate → MapPoll → WireGuard+netstack → Disco+STUN → keepalive - DialContext/ListenPacket/Peers/LookupTailscale API Verified: DERP relay 500ms+ → direct UDP 13ms after disco NAT traversal.
2026-04-02 11:48:15 +08:00
defer dc.Close()
if err := dc.ConnectRegion(ctx, derpMap, regionID); err != nil {
t.Fatalf("ConnectRegion %d: %v", regionID, err)
}
regions := dc.ConnectedRegions()
if len(regions) != 1 || regions[0] != regionID {
t.Fatalf("expected connected to region %d, got %v", regionID, regions)
}
t.Logf("connected to DERP region %d", regionID)
}
// TestDERPSendRecv verifies two clients can exchange packets via DERP.
func TestDERPSendRecv(t *testing.T) {
authKey := os.Getenv("TAILNET_AUTH_KEY")
if authKey == "" {
t.Skip("set TAILNET_AUTH_KEY to run DERP tests")
}
dir := t.TempDir()
ctrl, err := NewControlClient(ControlOptions{
StateDir: dir,
AuthKey: authKey,
Hostname: "derp-test-sr",
Logf: t.Logf,
})
if err != nil {
t.Fatalf("NewControlClient: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := ctrl.Register(ctx); err != nil {
t.Fatalf("Register: %v", err)
}
ch, err := ctrl.MapPoll(ctx)
if err != nil {
t.Fatalf("MapPoll: %v", err)
}
var derpMap = ctrl.DERPMap()
for derpMap == nil {
mr, ok := <-ch
if !ok {
t.Fatal("map channel closed without DERPMap")
}
if mr.DERPMap != nil {
derpMap = mr.DERPMap
}
}
// Pick a region
var regionID int
for id := range derpMap.Regions {
regionID = id
break
}
t.Logf("using DERP region %d (%s)", regionID, derpMap.Regions[regionID].RegionName)
// Two independent DERP clients with fresh keys
keyA := key.NewNode()
keyB := key.NewNode()
clientA := NewDERPClient(keyA, t.Logf, nil)
Implement complete Tailscale protocol stack: control, DERP, WireGuard, disco NAT traversal Clean-room implementation of the Tailscale protocol without tsnet/wgengine/netmon dependencies, enabling cross-platform support including Android (no NetlinkRIB). Control plane (control.go): - Noise IK handshake (ts2021) over HTTP/2 - Register, MapLite (non-streaming), MapPoll (streaming) - SendEndpointUpdate (Stream=false, OmitPeers=true) — the only way to push node state to control (streaming MapPoll is read-only for v>=68) Data plane (magicbind.go, derpclient.go): - conn.Bind implementation multiplexing direct UDP and DERP relay - DERP client with per-region connections and recv multiplexing - Close/CloseAll split to survive WireGuard IpcSet rebind cycle NAT traversal (disco.go): - STUN endpoint discovery via tailscale.com/net/stun (zero deps) - CallMeMaybe/Ping/Pong over DERP for hole punching - Direct UDP path establishment with latency tracking - Keepalive re-ping loop (30s) to maintain direct paths - UDP data forwarding from disco socket to MagicBind recv path DERP region selection (derpclient.go): - Parallel HTTPS latency probing of all DERP regions - Lowest-latency region selected as home DERP Orchestration (outbound.go): - Register → MapLite → ProbeRegions → ConnectDERP → SendEndpointUpdate → MapPoll → WireGuard+netstack → Disco+STUN → keepalive - DialContext/ListenPacket/Peers/LookupTailscale API Verified: DERP relay 500ms+ → direct UDP 13ms after disco NAT traversal.
2026-04-02 11:48:15 +08:00
defer clientA.Close()
clientB := NewDERPClient(keyB, t.Logf, nil)
Implement complete Tailscale protocol stack: control, DERP, WireGuard, disco NAT traversal Clean-room implementation of the Tailscale protocol without tsnet/wgengine/netmon dependencies, enabling cross-platform support including Android (no NetlinkRIB). Control plane (control.go): - Noise IK handshake (ts2021) over HTTP/2 - Register, MapLite (non-streaming), MapPoll (streaming) - SendEndpointUpdate (Stream=false, OmitPeers=true) — the only way to push node state to control (streaming MapPoll is read-only for v>=68) Data plane (magicbind.go, derpclient.go): - conn.Bind implementation multiplexing direct UDP and DERP relay - DERP client with per-region connections and recv multiplexing - Close/CloseAll split to survive WireGuard IpcSet rebind cycle NAT traversal (disco.go): - STUN endpoint discovery via tailscale.com/net/stun (zero deps) - CallMeMaybe/Ping/Pong over DERP for hole punching - Direct UDP path establishment with latency tracking - Keepalive re-ping loop (30s) to maintain direct paths - UDP data forwarding from disco socket to MagicBind recv path DERP region selection (derpclient.go): - Parallel HTTPS latency probing of all DERP regions - Lowest-latency region selected as home DERP Orchestration (outbound.go): - Register → MapLite → ProbeRegions → ConnectDERP → SendEndpointUpdate → MapPoll → WireGuard+netstack → Disco+STUN → keepalive - DialContext/ListenPacket/Peers/LookupTailscale API Verified: DERP relay 500ms+ → direct UDP 13ms after disco NAT traversal.
2026-04-02 11:48:15 +08:00
defer clientB.Close()
if err := clientA.ConnectRegion(ctx, derpMap, regionID); err != nil {
t.Fatalf("clientA connect: %v", err)
}
if err := clientB.ConnectRegion(ctx, derpMap, regionID); err != nil {
t.Fatalf("clientB connect: %v", err)
}
// Give DERP server a moment to register both clients
time.Sleep(500 * time.Millisecond)
// A sends to B
payload := []byte("hello from A to B via DERP")
if err := clientA.Send(regionID, keyB.Public(), payload); err != nil {
t.Fatalf("Send A→B: %v", err)
}
t.Logf("A sent %d bytes to B", len(payload))
// B receives
select {
case result := <-clientB.RecvChan():
if result.Source != keyA.Public() {
t.Fatalf("expected source %s, got %s", keyA.Public().ShortString(), result.Source.ShortString())
}
if string(result.Data) != string(payload) {
t.Fatalf("expected %q, got %q", payload, result.Data)
}
t.Logf("B received %d bytes from A: %q", len(result.Data), result.Data)
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for packet from A")
}
// B sends back to A
reply := []byte("reply from B to A")
if err := clientB.Send(regionID, keyA.Public(), reply); err != nil {
t.Fatalf("Send B→A: %v", err)
}
select {
case result := <-clientA.RecvChan():
if result.Source != keyB.Public() {
t.Fatalf("expected source %s, got %s", keyB.Public().ShortString(), result.Source.ShortString())
}
if string(result.Data) != string(reply) {
t.Fatalf("expected %q, got %q", reply, result.Data)
}
t.Logf("A received %d bytes from B: %q", len(result.Data), result.Data)
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for reply from B")
}
}
Add comprehensive unit tests, chaos tests, and E2E test coverage Unit tests (no network required): - TestUpgradeNode: LegacyDERPString parsing (8 cases) - TestNodeKeyHex: key hex format validation - TestHostinfo: OS and hostname fields - TestEndpointsChanged: set comparison (5 cases) - TestSetPeerPreservesPath: path state preserved on disco key match - TestSetPeerResetsOnDiscoKeyChange: full reset on key change - TestPathDemotion: RTT spike → demotion → restoration - TestConsecutiveFailsDemotion: ping timeout → path demotion - TestMagicEndpointFormat: DstToString/DstToBytes format - TestParseEndpointRoundtrip: endpoint serialize/deserialize - TestMagicBindMetrics: initial zero, atomic counter snapshot - TestMagicBindOpenClose: Open/Close/Open lifecycle - TestDERPClientCloseIdempotent: triple Close safe - TestDERPClientClosedSendError: Send after Close - TestDERPClientConnectedRegionsEmpty: empty initial state - TestLookupTailscale: short name, FQDN, case-insensitive Chaos tests (concurrent access, race detection): - TestConcurrentPeerUpdates: 10 goroutines × UpdatePeers - TestConcurrentDiscoSetPeer: 10 goroutines × SetPeer - TestMagicBindSendWithoutDERP: no-DERP error path - TestMagicBindSendWithClosedBind: post-Close behavior - TestDERPClientConcurrentClose: 10 goroutines × Close - TestDiscoManagerConcurrentProbe: probing guard coalescing - TestNetWatcherPoke: callback trigger and coalescing - TestNetWatcherClose: idempotent Close - TestDiscoMetricsConcurrent: Metrics under contention - TestMagicBindMetricsConcurrent: atomic counters under 5 writers - TestUpgradeNodeConcurrent: parallel upgradeNode - TestDERPClientSendFallback: unhealthy region fallback Also: extracted handlePongLocked for testability in disco.go
2026-04-03 04:56:31 +08:00
// TestDERPClientCloseIdempotent verifies that calling Close multiple times
// does not panic and returns nil.
func TestDERPClientCloseIdempotent(t *testing.T) {
dc := NewDERPClient(key.NewNode(), t.Logf, nil)
// First close
if err := dc.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
// Second close should not panic
if err := dc.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
// Third close for good measure
if err := dc.Close(); err != nil {
t.Fatalf("third Close: %v", err)
}
t.Logf("triple Close without panic: OK")
}
// TestDERPClientClosedSendError verifies that Send returns an error after Close.
func TestDERPClientClosedSendError(t *testing.T) {
dc := NewDERPClient(key.NewNode(), t.Logf, nil)
dc.Close()
dstKey := key.NewNode().Public()
err := dc.Send(1, dstKey, []byte("hello"))
if err == nil {
t.Fatal("expected error from Send after Close, got nil")
}
t.Logf("Send after Close correctly returned: %v", err)
}
// TestDERPClientConnectedRegionsEmpty verifies that ConnectedRegions returns
// an empty slice when no regions have been connected.
func TestDERPClientConnectedRegionsEmpty(t *testing.T) {
dc := NewDERPClient(key.NewNode(), t.Logf, nil)
defer dc.Close()
regions := dc.ConnectedRegions()
if len(regions) != 0 {
t.Fatalf("expected 0 connected regions, got %v", regions)
}
t.Logf("ConnectedRegions with no connects: [] (OK)")
}