mirror of
https://github.com/tailscale/tailscale.git
synced 2026-06-25 00:11:39 -04:00
tstest/natlab/vnet: send unsolicited IPv6 Router Advertisements
vnet only ever sent IPv6 RAs in response to a Router Solicitation. In
practice this meant gokrazy VMs running with a dual-stack LAN never
installed vnet's IPv6 default route: gokrazy brings the link up via
DHCPv4 and the kernel never emits an RS on its own under that init
path. Off-link IPv6 destinations like the fake DERP servers were
therefore unreachable from any gokrazy test node that also had v4
on the same interface. (Pure-v6 nodes happened to work because the
kernel sends an RS as part of v6-only autoconf.)
Fix this in two complementary ways:
- Send an unsolicited RA every 5s to the link-local all-nodes group
on every v6-enabled network. This matches what real routers do
(RFC 4861 §6.2.1, MaxRtrAdvInterval; we use a much shorter
interval than the spec's 200s default so short-lived tests don't
have to wait).
- Send a unicast RA to a newly-registered MAC as soon as a client
first transmits on the wire. Without this the first periodic RA
can land before any VM has connected and the next one isn't
until the next tick, which can be longer than the test runs.
Factor the RA serialization out into buildIPv6RouterAdvertisement so
the solicited, periodic, and per-client paths all share one body.
Update TestSelfSignedDERPHashPinning to use a dual-stack hard-NAT
builder and assert zero errors from DebugDERPRegion (instead of
filtering "over IPv6" errors as it had to before this change). The
new builder also sets TS_DEBUG_STRIP_ENDPOINTS=1 on tailscaled so
disco can't find a direct path: without endpoint stripping, the now-
working non-NATted IPv6 LAN gives the two hard-NAT'd nodes a direct
route, defeating the test's "must traverse DERP" assertion. (Hard
NAT alone was enough before this change because v6 routing was
broken.) Also update sendBetweenClients in the vnet unit tests to
tolerate the new on-register RA noise on its read path.
Updates #13038
Updates #19973
Change-Id: Ic281dc53702a25fa773c46313f453837814233e8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
c91b7188e8
commit
9107354488
@@ -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:<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 @@
|
||||
// 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, "; "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user