diff --git a/tstest/natlab/vmtest/selfsignedderp_test.go b/tstest/natlab/vmtest/selfsignedderp_test.go index 6754eaeaf..e41b4e552 100644 --- a/tstest/natlab/vmtest/selfsignedderp_test.go +++ b/tstest/natlab/vmtest/selfsignedderp_test.go @@ -5,22 +5,46 @@ import ( "context" + "fmt" "strings" "testing" "time" "tailscale.com/tstest/natlab/vmtest" + "tailscale.com/tstest/natlab/vnet" ) +// hardDualNoEndpoints is hard NAT with both an IPv4 LAN and an IPv6 prefix +// (so the DebugDERPRegion probe exercises both address families against the +// test DERP server) and TS_DEBUG_STRIP_ENDPOINTS=1 set on tailscaled so it +// doesn't announce any direct endpoints to peers. Combined on both nodes, +// that leaves DERP as the only available path for the tailnet ping. The +// home DERP itself is left alone so the sha256-raw verification path is +// still exercised. +func hardDualNoEndpoints(env *vmtest.Env) *vmtest.Node { + n := env.NumNodes() + return env.AddNode(fmt.Sprintf("node-%d", n), + env.AddNetwork( + fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP + fmt.Sprintf("10.0.%d.1/24", n), + v6cidr(n), + vnet.HardNAT), + vnet.TailscaledEnv{Key: "TS_DEBUG_STRIP_ENDPOINTS", Value: "1"}, + vmtest.OS(vmtest.Gokrazy)) +} + // TestSelfSignedDERPHashPinning exercises the sha256-raw DERP cert pinning // code path end-to-end: tailscaled connects to its home DERP whose cert is // self-signed and pinned via CertName="sha256-raw:" (no separate // fronting CertName), the two nodes communicate over the resulting tailnet, // and `tailscale debug derp` against the same region succeeds. // -// Both nodes sit behind hard NATs with no port mapping available so disco -// cannot punch a direct path and the tailnet ping must traverse DERP, making -// the sha256-raw pinning of the tailscaled→DERP path part of the assertion. +// Both nodes sit behind hard NATs and additionally strip their direct +// endpoints (TS_DEBUG_STRIP_ENDPOINTS=1) so disco cannot find a direct path +// and the tailnet ping must traverse DERP, making the sha256-raw pinning of +// the tailscaled→DERP path part of the assertion. (Stripping endpoints — not +// just relying on hard NAT — is needed because the dual-stack LAN provides a +// non-NATted IPv6 path that the nodes would otherwise discover.) // // The debug-derp half is the regression test for the bug fixed in PR #19965: // before that change, [ipn/localapi.serveDebugDERPRegion] passed the raw @@ -28,8 +52,8 @@ // failed with a hostname mismatch. func TestSelfSignedDERPHashPinning(t *testing.T) { env := vmtest.New(t, vmtest.SelfSignedDERPCertPinning()) - n1 := hard(env) - n2 := hard(env) + n1 := hardDualNoEndpoints(env) + n2 := hardDualNoEndpoints(env) env.Start() if err := env.PingExpect(n1, n2, vmtest.PingRouteDERP, 60*time.Second); err != nil { @@ -46,15 +70,9 @@ func TestSelfSignedDERPHashPinning(t *testing.T) { } t.Logf("[%s] DebugDERPRegion(1): info=%v warnings=%v errors=%v", n.Name(), rep.Info, rep.Warnings, rep.Errors) - for _, e := range rep.Errors { - // The `hard` builder gives the node only an IPv4 LAN, so the - // DebugDERPRegion IPv6 probe predictably fails with - // "network is unreachable". That's orthogonal to the TLS - // verification path this test exists to cover. - if strings.Contains(e, "over IPv6") { - continue - } - t.Errorf("[%s] DebugDERPRegion(1) error: %s", n.Name(), e) + if len(rep.Errors) > 0 { + t.Errorf("[%s] DebugDERPRegion(1) reported errors: %s", + n.Name(), strings.Join(rep.Errors, "; ")) } } } diff --git a/tstest/natlab/vnet/vnet.go b/tstest/natlab/vnet/vnet.go index ffe6d3021..1a067e492 100644 --- a/tstest/natlab/vnet/vnet.go +++ b/tstest/natlab/vnet/vnet.go @@ -200,6 +200,8 @@ func (n *network) initStack() error { Destination: ipv6Subnet, NIC: nicID, }) + + n.startUnsolicitedRAs() } n.ns.SetRouteTable(routes) @@ -627,6 +629,17 @@ func (n *network) registerWriter(mac MAC, c vmClient) { nw.interfaceID = node.interfaceID } n.writers.Store(mac, nw) + + // As soon as a host appears on the wire, hand it a Router Advertisement + // so its kernel installs the prefix + default route. Without this, hosts + // that never emit a Router Solicitation (e.g. gokrazy with DHCPv4 doing + // link bringup) would have to wait for the next periodic RA, by which + // point the test may have already failed. + if n.v6 { + if pkt, err := n.buildIPv6RouterAdvertisement(mac, ipv6AllNodes); err == nil { + n.writeEth(pkt) + } + } } func (n *network) unregisterWriter(mac MAC) { @@ -1926,21 +1939,36 @@ func (n *network) handleUDPPacketForRouter(ep EthernetPacket, udp *layers.UDP, t n.logf("router got unknown UDP packet: %v", packet) } -func (n *network) handleIPv6RouterSolicitation(ep EthernetPacket, rs *layers.ICMPv6RouterSolicitation) { - v6 := ep.gp.Layer(layers.LayerTypeIPv6).(*layers.IPv6) +// ipv6AllNodes is the IPv6 link-local "all nodes" multicast address (ff02::1). +// Unsolicited Router Advertisements are sent here so that every connected +// host on the LAN sees them without the router having to know each host's +// unicast address. +var ipv6AllNodes = net.ParseIP("ff02::1") - // Send a router advertisement back. +// unsolicitedRAInterval is how often vnet sends an unsolicited IPv6 Router +// Advertisement on each v6-enabled network. Real routers default to 200s +// (RFC 4861 §6.2.1, MaxRtrAdvInterval). We pick a much smaller value so +// short-lived tests don't have to wait: the first RA goes out as soon as +// a VM connects, and any subsequent gokrazy/Linux init paths that miss the +// initial RA pick one up quickly. +const unsolicitedRAInterval = 5 * time.Second + +// buildIPv6RouterAdvertisement serializes a Router Advertisement frame +// addressed to (dstMAC, dstIP), advertising n.wanIP6's /64 as on-link and +// fe80::1 as a default router. dstMAC/dstIP are typically the soliciting +// host (for a solicited reply) or the link-local all-nodes group (for an +// unsolicited periodic RA). +func (n *network) buildIPv6RouterAdvertisement(dstMAC MAC, dstIP net.IP) ([]byte, error) { eth := &layers.Ethernet{ SrcMAC: n.mac.HWAddr(), - DstMAC: ep.SrcMAC().HWAddr(), + DstMAC: dstMAC.HWAddr(), EthernetType: layers.EthernetTypeIPv6, } - n.logf("sending IPv6 router advertisement to %v from %v", eth.DstMAC, eth.SrcMAC) ip := &layers.IPv6{ NextHeader: layers.IPProtocolICMPv6, HopLimit: 255, // per RFC 4861, 7.1.1 etc (all NDP messages); don't use mkPacket's default of 64 SrcIP: net.ParseIP("fe80::1"), - DstIP: v6.SrcIP, + DstIP: dstIP, } icmp := &layers.ICMPv6{ TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeRouterAdvertisement, 0), @@ -1963,7 +1991,13 @@ func (n *network) handleIPv6RouterSolicitation(ep EthernetPacket, rs *layers.ICM }, }, } - pkt, err := mkPacket(eth, ip, icmp, ra) + return mkPacket(eth, ip, icmp, ra) +} + +func (n *network) handleIPv6RouterSolicitation(ep EthernetPacket, _ *layers.ICMPv6RouterSolicitation) { + v6 := ep.gp.Layer(layers.LayerTypeIPv6).(*layers.IPv6) + n.logf("sending IPv6 router advertisement to %v from %v", ep.SrcMAC(), n.mac) + pkt, err := n.buildIPv6RouterAdvertisement(ep.SrcMAC(), v6.SrcIP) if err != nil { n.logf("serializing ICMPv6 RA: %v", err) return @@ -1971,6 +2005,37 @@ func (n *network) handleIPv6RouterSolicitation(ep EthernetPacket, rs *layers.ICM n.writeEth(pkt) } +// startUnsolicitedRAs sends an unsolicited Router Advertisement to the +// link-local all-nodes group every unsolicitedRAInterval until the vnet +// server shuts down. This ensures hosts on the LAN install vnet's default +// IPv6 route even if their stack never emits a Router Solicitation, which +// is what gokrazy's dual-stack init does in practice: it brings the link +// up via DHCPv4 and then leaves IPv6 to the kernel, which under our +// configuration never sends an RS. +func (n *network) startUnsolicitedRAs() { + n.s.wg.Go(func() { + send := func() { + pkt, err := n.buildIPv6RouterAdvertisement(macAllNodes, ipv6AllNodes) + if err != nil { + n.logf("building unsolicited RA: %v", err) + return + } + n.writeEth(pkt) + } + send() + t := time.NewTicker(unsolicitedRAInterval) + defer t.Stop() + for { + select { + case <-n.s.shutdownCtx.Done(): + return + case <-t.C: + send() + } + } + }) +} + func (n *network) handleIPv6NeighborSolicitation(ep EthernetPacket, ns *layers.ICMPv6NeighborSolicitation) { v6 := ep.gp.Layer(layers.LayerTypeIPv6).(*layers.IPv6) diff --git a/tstest/natlab/vnet/vnet_test.go b/tstest/natlab/vnet/vnet_test.go index 9d7c78c45..c35ea4442 100644 --- a/tstest/natlab/vnet/vnet_test.go +++ b/tstest/natlab/vnet/vnet_test.go @@ -637,15 +637,22 @@ func sendBetweenClients(t testing.TB, clientc [2]*net.UnixConn, s *Server, wrap t.Logf("writing % 02x", pkt) must.Get(clientc[0].Write(pkt)) - buf := make([]byte, len(pkt)) - clientc[1].SetReadDeadline(time.Now().Add(5 * time.Second)) - n, err := clientc[1].Read(buf) - if err != nil { - t.Fatal(err) - } - got := buf[:n] - if !bytes.Equal(got, pkt) { - t.Errorf("bad packet\n got: % 02x\nwant: % 02x", got, pkt) + // vnet sends an unsolicited Router Advertisement at writer-register time + // on v6-enabled networks; loop until we see the test packet, skipping any + // noise that arrived first. + buf := make([]byte, 2048) + deadline := time.Now().Add(5 * time.Second) + for { + clientc[1].SetReadDeadline(deadline) + n, err := clientc[1].Read(buf) + if err != nil { + t.Fatalf("did not receive test packet: %v", err) + } + got := buf[:n] + if bytes.Equal(got, pkt) { + return + } + t.Logf("ignoring unexpected packet (% 02x...)", got[:min(len(got), 16)]) } }