WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
3 changed files with 120 additions and 30 deletions
Showing only changes of commit 9107354488 - Show all commits
+32 -14
View File
@@ -5,22 +5,46 @@ package vmtest_test
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:<hex>" (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 @@ import (
// 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, "; "))
}
}
}
+72 -7
View File
@@ -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)
+16 -9
View File
@@ -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)])
}
}