diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index ac9689f47..a256f9c10 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -91,6 +91,7 @@ type Env struct { sameTailnetUser bool // all nodes register as the same Tailnet user allOnline bool // mark every peer as Online=true in MapResponses + peerRelayGrants bool // grant peer-relay capabilities on the wildcard packet filter // Shared resource initialization (sync.Once for things multiple nodes share). vnetOnce sync.Once @@ -373,6 +374,16 @@ func AllOnline() EnvOption { return envOptFunc(func(e *Env) { e.allOnline = true }) } +// PeerRelayGrants returns an [EnvOption] that makes the test control server +// grant [tailcfg.PeerCapabilityRelay] and [tailcfg.PeerCapabilityRelayTarget] +// on the wildcard packet filter (testcontrol.Server.PeerRelayGrants). Without +// those capabilities, magicsock does not consider any peer a candidate +// peer-relay server, so a node that has [ipn.Prefs.RelayServerPort] set +// cannot actually be used as a relay by its peers. +func PeerRelayGrants() EnvOption { + return envOptFunc(func(e *Env) { e.peerRelayGrants = true }) +} + // AddNetwork creates a new virtual network. Arguments follow the same pattern as // vnet.Config.AddNetwork (string IPs, NAT types, NetworkService values). func (e *Env) AddNetwork(opts ...any) *vnet.Network { @@ -1365,6 +1376,9 @@ func (e *Env) initVnet() { if e.allOnline { e.server.ControlServer().AllOnline = true } + if e.peerRelayGrants { + e.server.ControlServer().PeerRelayGrants = true + } }) } diff --git a/tstest/natlab/vmtest/vmtest_test.go b/tstest/natlab/vmtest/vmtest_test.go index 3b94dc76c..ad8c6f296 100644 --- a/tstest/natlab/vmtest/vmtest_test.go +++ b/tstest/natlab/vmtest/vmtest_test.go @@ -5,6 +5,7 @@ package vmtest_test import ( "bytes" + "context" "fmt" "net/netip" "runtime" @@ -13,6 +14,8 @@ import ( "time" "tailscale.com/client/local" + "tailscale.com/ipn" + "tailscale.com/net/udprelay/status" "tailscale.com/tailcfg" "tailscale.com/tstest" "tailscale.com/tstest/integration/testcontrol" @@ -20,6 +23,7 @@ import ( "tailscale.com/tstest/natlab/vnet" "tailscale.com/types/key" "tailscale.com/types/netmap" + "tailscale.com/util/set" ) // skipIfNotMacOSArm64 skips the test when the host isn't a macOS arm64 host. @@ -1060,3 +1064,127 @@ func TestDirectConnectionWithCachedNetmapOnOneNode(t *testing.T) { }) checkFinalMetrics.End(nil) } + +// TestPeerRelay verifies that two Tailscale nodes whose direct UDP path is +// impossible at the network layer (both behind HardNAT, with no port-mapping +// services on either of their networks) can still communicate via a third +// Tailscale node configured as a peer-relay server. +// +// Topology: +// +// a (gokrazy, HardNAT) — aNet WAN 1.0.0.1 +// b (gokrazy, HardNAT) — bNet WAN 2.0.0.1 +// relay (gokrazy, One2OneNAT) — relayNet WAN 3.0.0.1 +// +// HardNAT in natlab is endpoint-dependent (each (src, dst) tuple gets a fresh +// outbound port, and the inbound table keys on (wanPort, src)). Without +// NAT-PMP/UPnP a→b and b→a direct UDP paths cannot be established. The relay +// uses One2OneNAT so its STUN-discovered WAN endpoint is reachable from both +// peers. The test then asserts that magicsock chose the peer-relay path +// (not DERP) and that the relay reports the session. +func TestPeerRelay(t *testing.T) { + env := vmtest.New(t, vmtest.PeerRelayGrants()) + + aNet := env.AddNetwork("1.0.0.1", "192.168.1.1/24", vnet.HardNAT) + bNet := env.AddNetwork("2.0.0.1", "192.168.2.1/24", vnet.HardNAT) + relayNet := env.AddNetwork("3.0.0.1", "192.168.3.1/24", vnet.One2OneNAT) + + a := env.AddNode("a", aNet, vmtest.OS(vmtest.Gokrazy)) + b := env.AddNode("b", bNet, vmtest.OS(vmtest.Gokrazy)) + relay := env.AddNode("relay", relayNet, vmtest.OS(vmtest.Gokrazy)) + + enableRelayStep := env.AddStep("Enable peer-relay server on relay") + pingStep := env.AddStep("Disco ping a → b (want peer-relay path)") + sessionsStep := env.AddStep("Check DebugPeerRelaySessions on relay") + + env.Start() + + // Turn on the relay server. Port 0 picks an unused port. + enableRelayStep.Begin() + editCtx, editCancel := context.WithTimeout(t.Context(), 30*time.Second) + _, err := relay.Agent().EditPrefs(editCtx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{RelayServerPort: new(uint16(0))}, + RelayServerPortSet: true, + }) + editCancel() + if err != nil { + enableRelayStep.Fatalf("EditPrefs(relay, RelayServerPort=0): %v", err) + } + enableRelayStep.End(nil) + + // Wait for the relay to start, peers to learn about it via netmap, + // and the a→b disco ping to traverse it. + // PingResult.PeerRelay is set by magicsock to "ip:port:vni:N" when the + // disco probe rode a peer relay (vs Endpoint for direct UDP or + // DERPRegionID for DERP). + pingStep.Begin() + bIP := env.Status(b).Self.TailscaleIPs[0] + var lastDetail string + err = tstest.WaitFor(60*time.Second, func() error { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + pr, err := a.Agent().PingWithOpts(ctx, bIP, tailcfg.PingDisco, local.PingOpts{}) + if err != nil { + return fmt.Errorf("ping: %w", err) + } + if pr.Err != "" { + return fmt.Errorf("ping err: %s", pr.Err) + } + if pr.PeerRelay == "" { + lastDetail = fmt.Sprintf("endpoint=%q derp=%d", pr.Endpoint, pr.DERPRegionID) + return fmt.Errorf("ping did not use a peer relay; %s", lastDetail) + } + t.Logf("a → b disco ping rode peer-relay %s", pr.PeerRelay) + return nil + }) + if err != nil { + env.DumpStatus(a) + env.DumpStatus(b) + env.DumpStatus(relay) + pingStep.Fatalf("waiting for peer-relay path a → b: %v (last: %s)", err, lastDetail) + } + pingStep.End(nil) + + // The relay's local debug-peer-relay-sessions LocalAPI should now + // report a single session for the a↔b disco probe. Cross-check the + // session's client disco keys against control's view of a and b, and + // confirm both sides recorded non-zero packet/byte counts (the disco + // ping + pong each take one underlay packet through the relay). + sessionsStep.Begin() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + srv, err := relay.Agent().DebugPeerRelaySessions(ctx) + if err != nil { + sessionsStep.Fatalf("DebugPeerRelaySessions: %v", err) + } + if srv.UDPPort == nil { + sessionsStep.Fatalf("relay UDPPort is nil; want set") + } + if got, want := len(srv.Sessions), 1; got != want { + sessionsStep.Fatalf("relay sessions = %d; want %d: %+v", got, want, srv.Sessions) + } + cs := env.ControlServer() + wantShorts := set.Of( + cs.Node(env.Status(a).Self.PublicKey).DiscoKey.ShortString(), + cs.Node(env.Status(b).Self.PublicKey).DiscoKey.ShortString(), + ) + session := srv.Sessions[0] + gotShorts := set.Of(session.Client1.ShortDisco, session.Client2.ShortDisco) + if !gotShorts.Equal(wantShorts) { + sessionsStep.Fatalf("session disco shorts = %v; want %v", gotShorts, wantShorts) + } + for _, ci := range []status.ClientInfo{session.Client1, session.Client2} { + if !ci.Endpoint.IsValid() { + sessionsStep.Fatalf("session client %s: invalid Endpoint", ci.ShortDisco) + } + if ci.PacketsTx == 0 { + sessionsStep.Fatalf("session client %s: PacketsTx = 0; want >0", ci.ShortDisco) + } + if ci.BytesTx == 0 { + sessionsStep.Fatalf("session client %s: BytesTx = 0; want >0", ci.ShortDisco) + } + } + t.Logf("relay session VNI=%d %s <-> %s on UDP port %d", + session.VNI, session.Client1.ShortDisco, session.Client2.ShortDisco, *srv.UDPPort) + sessionsStep.End(nil) +}