From 77d2c87b17e722561b38ee9e69f56ef34d84f5c5 Mon Sep 17 00:00:00 2001 From: Brendan Creane Date: Wed, 24 Jun 2026 13:41:36 -0700 Subject: [PATCH] wgengine/router/osrouter,util/linuxfw: remove orphaned tailnet addrs (#20199) Router.Set reconciled tailscale0's addresses only against the in-memory r.addrs map, which starts empty each run. After a restart the kernel can still hold the addresses a previous profile put on tailscale0. With no record of them, Set never removed them, leaving two tailnets' CGNAT addresses on the interface. That broke connectivity, because the kernel could source traffic from the wrong IP. Fix this by scanning the addresses actually on the interface and, after reconciling the desired set, removing any in Tailscale's CGNAT/ULA ranges that aren't in the config. Non-Tailscale addresses are never touched, and IPv6 addresses are skipped when IPv6 is unavailable, since delAddress no-ops there. To avoid a netlink dump on every Set, the scan runs only on the first Set and when the desired address set changes. This also needs the iptables DelLoopbackRule to tolerate a missing rule: an orphan left by a previous instance never went through AddLoopbackRule here, and iptables (unlike nftables) errors when deleting an absent rule, which would otherwise block the address delete. Fixes #19974 Signed-off-by: Brendan Creane --- util/linuxfw/iptables_runner.go | 16 +- util/linuxfw/iptables_runner_test.go | 33 ++ wgengine/router/osrouter/router_linux.go | 136 +++++++- wgengine/router/osrouter/router_linux_test.go | 330 +++++++++++++++++- 4 files changed, 509 insertions(+), 6 deletions(-) diff --git a/util/linuxfw/iptables_runner.go b/util/linuxfw/iptables_runner.go index 52b9a29fb..ad983fb75 100644 --- a/util/linuxfw/iptables_runner.go +++ b/util/linuxfw/iptables_runner.go @@ -95,9 +95,21 @@ func tsChain(chain string) string { } // DelLoopbackRule removes the iptables rule permitting loopback -// traffic to a Tailscale IP. +// traffic to a Tailscale IP. A missing rule is not an error: an address +// left on the interface by a previous tailscaled instance never went +// through AddLoopbackRule in this one, so removing it must not be +// blocked by the absence of its loopback rule. func (i *iptablesRunner) DelLoopbackRule(addr netip.Addr) error { - if err := i.getIPTByAddr(addr).Delete("filter", "ts-input", "-i", "lo", "-s", addr.String(), "-j", "ACCEPT"); err != nil { + ipt := i.getIPTByAddr(addr) + args := []string{"-i", "lo", "-s", addr.String(), "-j", "ACCEPT"} + exists, err := ipt.Exists("filter", "ts-input", args...) + if err != nil { + return fmt.Errorf("checking loopback allow rule for %q: %w", addr, err) + } + if !exists { + return nil + } + if err := ipt.Delete("filter", "ts-input", args...); err != nil { return fmt.Errorf("deleting loopback allow rule for %q: %w", addr, err) } diff --git a/util/linuxfw/iptables_runner_test.go b/util/linuxfw/iptables_runner_test.go index 5bf624ef4..f31080044 100644 --- a/util/linuxfw/iptables_runner_test.go +++ b/util/linuxfw/iptables_runner_test.go @@ -557,3 +557,36 @@ func TestAddAndDelCGNATRules(t *testing.T) { } } } + +// TestDelLoopbackRuleMissing verifies DelLoopbackRule is a no-op (not an error) +// when the rule is absent, so removing an address whose loopback rule was never +// added in this instance -- e.g. one left on the interface by a previous +// tailscaled -- isn't blocked. See tailscale/tailscale#19974. +func TestDelLoopbackRuleMissing(t *testing.T) { + iptr := newFakeIPTablesRunner() + if err := iptr.AddChains(); err != nil { + t.Fatal(err) + } + defer iptr.DelChains() + + addr := netip.MustParseAddr("100.64.0.99") + rule := []string{"-i", "lo", "-s", addr.String(), "-j", "ACCEPT"} + + // No AddLoopbackRule for addr, so its rule is absent. Delete must not error. + if err := iptr.DelLoopbackRule(addr); err != nil { + t.Fatalf("DelLoopbackRule with no rule present: %v", err) + } + + // And it still deletes the rule when present. + if err := iptr.AddLoopbackRule(addr); err != nil { + t.Fatal(err) + } + if err := iptr.DelLoopbackRule(addr); err != nil { + t.Fatalf("DelLoopbackRule with rule present: %v", err) + } + if exists, err := iptr.ipt4.Exists("filter", "ts-input", rule...); err != nil { + t.Fatal(err) + } else if exists { + t.Error("loopback rule still present after DelLoopbackRule") + } +} diff --git a/wgengine/router/osrouter/router_linux.go b/wgengine/router/osrouter/router_linux.go index 73f65cdf1..3cd789833 100644 --- a/wgengine/router/osrouter/router_linux.go +++ b/wgengine/router/osrouter/router_linux.go @@ -6,6 +6,7 @@ package osrouter import ( + "bytes" "errors" "fmt" "net" @@ -27,12 +28,14 @@ "tailscale.com/envknob" "tailscale.com/health" "tailscale.com/net/netmon" + "tailscale.com/net/tsaddr" "tailscale.com/tsconst" "tailscale.com/types/logger" "tailscale.com/types/opt" "tailscale.com/types/preftype" "tailscale.com/util/eventbus" "tailscale.com/util/linuxfw" + "tailscale.com/util/set" "tailscale.com/version/distro" "tailscale.com/wgengine/router" ) @@ -82,6 +85,7 @@ type linuxRouter struct { mu sync.Mutex addrs map[netip.Prefix]bool + lastScanAddrs set.Set[netip.Prefix] // desired addrs at the last successful orphan scan; nil until the first scan routes map[netip.Prefix]bool localRoutes map[netip.Prefix]bool snatSubnetRoutes bool @@ -450,12 +454,48 @@ func (r *linuxRouter) Set(cfg *router.Config) error { } r.routes = newRoutes + prevAddrs := r.addrs newAddrs, err := cidrDiff("addr", r.addrs, cfg.LocalAddrs, r.addAddress, r.delAddress, r.logf) if err != nil { errs = append(errs, err) } r.addrs = newAddrs + // r.addrs only tracks what this instance configured, so it misses + // Tailscale addresses a previous instance left on a persistent tailscale0. + // After the reconcile above (so our own addresses and their loopback rules + // are installed first), sweep any Tailscale-range interface address that + // isn't desired and that we don't already track (prevAddrs). Like cidrDiff, + // this trusts cfg.LocalAddrs to be authoritative. See #19974. + // + // TODO(bcreane): a late orphan with no config change -- IPv6 becoming + // available, or an external re-add -- isn't caught here; re-run this sweep on + // netmon.ChangeDelta to handle it. See tailscale/corp#43882. + wantAddrs := set.SetOf(cfg.LocalAddrs) + if r.lastScanAddrs == nil || !r.lastScanAddrs.Equal(wantAddrs) { + if kernelAddrs, err := r.tailscaleInterfaceAddrs(); err != nil { + r.logf("router: enumerating interface addresses failed, skipping orphan cleanup: %v", err) + } else { + r.lastScanAddrs = wantAddrs + orphaned := orphanedAddrs(kernelAddrs, cfg.LocalAddrs) + removed := make([]netip.Prefix, 0, len(orphaned)) + for _, p := range orphaned { + if prevAddrs[p] { + continue // an address we were tracking; cidrDiff already handled it + } + if err := r.delAddress(p); err != nil { + r.logf("router: removing stale address %v from %s failed: %v", p, r.tunname, err) + errs = append(errs, err) + continue + } + removed = append(removed, p) + } + if len(removed) > 0 { + r.logf("router: removed %d stale Tailscale address(es) from %s left by a previous instance: %v", len(removed), r.tunname, removed) + } + } + } + // Ensure that the SNAT rule is added or removed as needed. switch { case cfg.SNATSubnetRoutes == r.snatSubnetRoutes: @@ -844,11 +884,19 @@ func (r *linuxRouter) setNetfilterModeLocked(mode preftype.NetfilterMode) error // getV6FilteringAvailable returns true if the router is able to setup the // required tailscale filter rules for IPv6. func (r *linuxRouter) getV6FilteringAvailable() bool { + if r.nfr == nil { + return false + } return r.nfr.HasIPV6() && r.nfr.HasIPV6Filter() } -// getV6Available returns true if the host supports IPv6. +// getV6Available reports whether the router can manage IPv6. r.nfr can be nil if +// setupNetfilterLocked failed earlier in Set (which continues on error), so +// treat a nil runner as no IPv6 rather than dereferencing it. func (r *linuxRouter) getV6Available() bool { + if r.nfr == nil { + return false + } return r.nfr.HasIPV6() } @@ -904,6 +952,92 @@ func (r *linuxRouter) delAddress(addr netip.Prefix) error { return nil } +// reconcilableTailscaleIP reports whether ip, found on the tunnel interface, is +// a Tailscale-range address the router can actually delete. delAddress no-ops on +// IPv6 when IPv6 is unavailable, so excluding those avoids treating a no-op +// "deletion" as success. +func (r *linuxRouter) reconcilableTailscaleIP(ip netip.Addr) bool { + ip = ip.Unmap() + if !tsaddr.IsTailscaleIP(ip) { + return false + } + return !ip.Is6() || r.getV6Available() +} + +// tailscaleInterfaceAddrs returns the addresses on the tunnel interface that are +// within Tailscale's ranges and that the router can act on (see +// reconcilableTailscaleIP); all others are excluded so they're never candidates +// for removal. It's a filtered subset, and errors if the interface can't be read. +func (r *linuxRouter) tailscaleInterfaceAddrs() ([]netip.Prefix, error) { + if r.useIPCommand() { + return r.tailscaleInterfaceAddrsIPCommand() + } + link, err := r.link() + if err != nil { + return nil, err + } + addrs, err := netlink.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return nil, err + } + var ret []netip.Prefix + for _, a := range addrs { + if a.IPNet == nil { + continue + } + pfx, ok := netipx.FromStdIPNet(a.IPNet) + if !ok { + continue + } + // Preserve the kernel's prefix length so a later delete matches. + if r.reconcilableTailscaleIP(pfx.Addr()) { + ret = append(ret, pfx) + } + } + return ret, nil +} + +// tailscaleInterfaceAddrsIPCommand is the "ip" command implementation of +// tailscaleInterfaceAddrs, used in tests and when TS_DEBUG_USE_IP_COMMAND is +// set. +func (r *linuxRouter) tailscaleInterfaceAddrsIPCommand() ([]netip.Prefix, error) { + out, err := r.cmd.output("ip", "-oneline", "addr", "show", "dev", r.tunname) + if err != nil { + return nil, err + } + var ret []netip.Prefix + for line := range bytes.Lines(out) { + // `ip -oneline addr show` puts each address on one line as + // "inet " or "inet6 ". + fields := strings.Fields(string(line)) + for i := 0; i+1 < len(fields); i++ { + if fields[i] != "inet" && fields[i] != "inet6" { + continue + } + p, err := netip.ParsePrefix(fields[i+1]) + if err != nil { + break + } + if r.reconcilableTailscaleIP(p.Addr()) { + ret = append(ret, p) + } + break + } + } + return ret, nil +} + +// orphanedAddrs returns the addresses in kernelAddrs that are not in desired, +// i.e. the stale addresses left on the interface that the sweep should remove. +func orphanedAddrs(kernelAddrs, desired []netip.Prefix) []netip.Prefix { + if len(kernelAddrs) == 0 { + return nil + } + s := set.SetOf(kernelAddrs) + s.DeleteSlice(desired) + return s.Slice() +} + // addLoopbackRule adds a firewall rule to permit loopback traffic to // a local Tailscale IP. func (r *linuxRouter) addLoopbackRule(addr netip.Addr) error { diff --git a/wgengine/router/osrouter/router_linux_test.go b/wgengine/router/osrouter/router_linux_test.go index 340ebb148..9c74eceba 100644 --- a/wgengine/router/osrouter/router_linux_test.go +++ b/wgengine/router/osrouter/router_linux_test.go @@ -31,6 +31,7 @@ "tailscale.com/util/eventbus" "tailscale.com/util/eventbus/eventbustest" "tailscale.com/util/linuxfw" + "tailscale.com/util/set" "tailscale.com/version/distro" "tailscale.com/wgengine/router" ) @@ -561,7 +562,9 @@ type fakeIPTablesRunner struct { t *testing.T ipt4 map[string][]string ipt6 map[string][]string - // we always assume ipv6 and ipv6 nat are enabled when testing + // we always assume ipv6 and ipv6 nat are enabled when testing, unless + // noV6 is set. + noV6 bool addChainsErr error // if non-nil, AddChains returns it instead of setting up chains addConnmarkSaveCalls int @@ -1004,7 +1007,7 @@ func (n *fakeIPTablesRunner) DelExternalCGNATRules(mode linuxfw.CGNATMode, tunna return nil } -func (n *fakeIPTablesRunner) HasIPV6() bool { return true } +func (n *fakeIPTablesRunner) HasIPV6() bool { return !n.noV6 } func (n *fakeIPTablesRunner) HasIPV6NAT() bool { return true } func (n *fakeIPTablesRunner) HasIPV6Filter() bool { return true } @@ -1125,6 +1128,12 @@ func (o *fakeOS) run(args ...string) error { switch args[2] { case "add": if slices.Contains(*ls, rest) { + // addAddress uses netlink AddrReplace in production, which is + // idempotent; model that for addresses rather than erroring. + // Routes/rules keep strict add semantics. + if args[1] == "addr" { + return nil + } o.t.Errorf("can't add %q, already present", rest) return errors.New("already exists") } @@ -1162,8 +1171,29 @@ func (o *fakeOS) run(args ...string) error { } func (o *fakeOS) output(args ...string) ([]byte, error) { - want := "ip rule list priority 10000" got := strings.Join(args, " ") + + if got == "ip -oneline addr show dev tailscale0" { + // Render o.ips (entries look like " dev tailscale0") in a + // simplified `ip -oneline addr show` format that exposes the family token + // the parser keys off of. + var ret []string + for _, e := range o.ips { + cidr, _, _ := strings.Cut(e, " ") + p, err := netip.ParsePrefix(cidr) + if err != nil { + continue + } + fam := "inet" + if p.Addr().Is6() { + fam = "inet6" + } + ret = append(ret, fmt.Sprintf("3: tailscale0 %s %s scope global tailscale0", fam, cidr)) + } + return []byte(strings.Join(ret, "\n")), nil + } + + want := "ip rule list priority 10000" if got != want { o.t.Errorf("unexpected command that wants output: %v", got) return nil, errExec @@ -1609,3 +1639,297 @@ func TestSetSkipsNetfilterAddonsWhenSetupFails(t *testing.T) { nfr.addExternalCGNATCalls) } } + +// newTestLinuxRouter builds a linuxRouter backed by a fakeOS, brought up and +// ready for Set, mirroring TestSetSkipsNetfilterAddonsWhenSetupFails. +func newTestLinuxRouter(t *testing.T) (*linuxRouter, *fakeOS) { + t.Helper() + bus := eventbus.New() + t.Cleanup(bus.Close) + mon, err := netmon.New(bus, logger.Discard) + if err != nil { + t.Fatal(err) + } + mon.Start() + t.Cleanup(func() { mon.Close() }) + + fake := NewFakeOS(t) + ht := health.NewTracker(bus) + r, err := newUserspaceRouterAdvanced(logger.Discard, "tailscale0", mon, fake, ht, bus) + if err != nil { + t.Fatalf("newUserspaceRouterAdvanced: %v", err) + } + lr := r.(*linuxRouter) + lr.nfr = fake.nfr + if err := lr.Up(); err != nil { + t.Fatalf("Up: %v", err) + } + t.Cleanup(func() { lr.Close() }) + return lr, fake +} + +// TestSetRemovesOrphanedTailscaleAddrs verifies that Set removes Tailscale-range +// addresses left on the interface by a previous instance (issue 19974), even +// though they're absent from the in-memory r.addrs map. +func TestSetRemovesOrphanedTailscaleAddrs(t *testing.T) { + lr, fake := newTestLinuxRouter(t) + + // Simulate a tailscale0 that survived a restart still carrying a prior + // profile's CGNAT v4 and Tailscale ULA v6 addresses, plus a non-Tailscale + // address that must be left alone. + fake.ips = []string{ + "100.64.0.99/32 dev tailscale0", // CGNAT v4 orphan + "fd7a:115c:a1e0::99/128 dev tailscale0", // ULA v6 orphan + "192.168.1.5/24 dev tailscale0", // non-Tailscale, leave alone + } + slices.Sort(fake.ips) + + cfg := &Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32"), + NetfilterMode: netfilterOff, + } + if err := lr.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + + if !slices.Contains(fake.ips, "100.64.0.1/32 dev tailscale0") { + t.Errorf("desired addr 100.64.0.1/32 not present; ips=%q", fake.ips) + } + for _, gone := range []string{ + "100.64.0.99/32 dev tailscale0", + "fd7a:115c:a1e0::99/128 dev tailscale0", + } { + if slices.Contains(fake.ips, gone) { + t.Errorf("orphaned Tailscale addr %q was not removed; ips=%q", gone, fake.ips) + } + } + // Non-Tailscale address must be untouched. + if !slices.Contains(fake.ips, "192.168.1.5/24 dev tailscale0") { + t.Errorf("non-Tailscale addr 192.168.1.5/24 was wrongly removed; ips=%q", fake.ips) + } +} + +// TestSetRemovesOrphanWithNetfilter verifies orphan removal also works with +// netfilter enabled, where delAddress additionally tears down the address's +// loopback rule. An orphan from a previous instance has no such rule, so this +// guards that removing it isn't blocked by the loopback-rule deletion (see the +// iptables DelLoopbackRule DeleteIfExists handling). +func TestSetRemovesOrphanWithNetfilter(t *testing.T) { + lr, fake := newTestLinuxRouter(t) + + fake.ips = []string{ + "100.64.0.99/32 dev tailscale0", // CGNAT v4 orphan, no loopback rule + } + + cfg := &Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32"), + NetfilterMode: netfilterOn, + } + if err := lr.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + + if slices.Contains(fake.ips, "100.64.0.99/32 dev tailscale0") { + t.Errorf("orphan was not removed with netfilter on; ips=%q", fake.ips) + } + if !slices.Contains(fake.ips, "100.64.0.1/32 dev tailscale0") { + t.Errorf("desired addr 100.64.0.1/32 not present; ips=%q", fake.ips) + } +} + +// TestSetInstallsLoopbackRuleForExistingAddr covers the persisted-interface +// restart case: the node's own address is already on tailscale0 (so the kernel +// reports it), but its per-address loopback rule was flushed when the netfilter +// chains were rebuilt. Set must still install the loopback rule, i.e. it must +// not skip addAddress just because the address is already present -- which it +// would if a desired address were folded into the orphan set. See #19974. +func TestSetInstallsLoopbackRuleForExistingAddr(t *testing.T) { + lr, fake := newTestLinuxRouter(t) + + // The node's own address persisted on the interface across the restart. + fake.ips = []string{ + "100.64.0.1/32 dev tailscale0", + } + + cfg := &Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32"), + NetfilterMode: netfilterOn, + } + if err := lr.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + + nfr := fake.nfr.(*fakeIPTablesRunner) + wantRule := "-i lo -s 100.64.0.1 -j ACCEPT" + if !slices.Contains(nfr.ipt4["filter/ts-input"], wantRule) { + t.Errorf("loopback rule %q not installed for already-present addr; ts-input=%q", + wantRule, nfr.ipt4["filter/ts-input"]) + } +} + +// TestSetOrphanScanGatedByAddrChange verifies the interface is scanned for +// orphans on the first Set and when LocalAddrs changes, but not for route-only +// updates -- so a subnet router churning routes doesn't pay for a scan on every +// Set. +func TestSetOrphanScanGatedByAddrChange(t *testing.T) { + lr, fake := newTestLinuxRouter(t) + + // First Set establishes steady state (scans, finds nothing to remove). + if err := lr.Set(&Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32"), + NetfilterMode: netfilterOff, + }); err != nil { + t.Fatalf("Set 1: %v", err) + } + + // An orphan appears afterward. A route-only update leaves LocalAddrs + // unchanged, so the scan is skipped and the orphan stays in place. + fake.ips = append(fake.ips, "100.64.0.99/32 dev tailscale0") + slices.Sort(fake.ips) + if err := lr.Set(&Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32"), + Routes: mustCIDRs("10.0.0.0/24"), + NetfilterMode: netfilterOff, + }); err != nil { + t.Fatalf("Set 2 (route-only): %v", err) + } + if !slices.Contains(fake.ips, "100.64.0.99/32 dev tailscale0") { + t.Errorf("orphan removed on a route-only update; scan should have been skipped; ips=%q", fake.ips) + } + + // When LocalAddrs changes, the scan runs again and the orphan is removed. + if err := lr.Set(&Config{ + LocalAddrs: mustCIDRs("100.64.0.2/32"), + Routes: mustCIDRs("10.0.0.0/24"), + NetfilterMode: netfilterOff, + }); err != nil { + t.Fatalf("Set 3 (addr change): %v", err) + } + if slices.Contains(fake.ips, "100.64.0.99/32 dev tailscale0") { + t.Errorf("orphan not removed after LocalAddrs change; ips=%q", fake.ips) + } +} + +// TestSetOrphanScanNotRetriedOnDuplicateLocalAddrs verifies the scan gate +// compares the desired address set rather than its length against r.addrs. A +// duplicate in LocalAddrs (or, equivalently, an address that fails to install) +// leaves r.addrs smaller than len(LocalAddrs); a length-based gate would then +// rescan on every Set. The set comparison treats the desired set as unchanged. +func TestSetOrphanScanNotRetriedOnDuplicateLocalAddrs(t *testing.T) { + lr, fake := newTestLinuxRouter(t) + + // LocalAddrs with a duplicate: cidrDiff dedups, so r.addrs ends up smaller + // than len(LocalAddrs). + dupCfg := &Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32", "100.64.0.1/32"), + NetfilterMode: netfilterOff, + } + if err := lr.Set(dupCfg); err != nil { + t.Fatalf("Set 1: %v", err) + } + + // An orphan appears; re-applying the same (duplicated) LocalAddrs must not + // trigger a rescan, so the orphan stays. + fake.ips = append(fake.ips, "100.64.0.99/32 dev tailscale0") + slices.Sort(fake.ips) + if err := lr.Set(dupCfg); err != nil { + t.Fatalf("Set 2: %v", err) + } + if !slices.Contains(fake.ips, "100.64.0.99/32 dev tailscale0") { + t.Errorf("rescan ran for an unchanged (duplicated) LocalAddrs; ips=%q", fake.ips) + } +} + +// TestSetKeepsNonTailscaleAddrs is the safety check: Set must never remove +// addresses outside Tailscale's ranges, even when they're not in the config. +func TestSetKeepsNonTailscaleAddrs(t *testing.T) { + lr, fake := newTestLinuxRouter(t) + + fake.ips = []string{ + "192.168.1.5/24 dev tailscale0", // non-Tailscale v4 + "fe80::1/64 dev tailscale0", // link-local v6 + } + slices.Sort(fake.ips) + + cfg := &Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32"), + NetfilterMode: netfilterOff, + } + if err := lr.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + + for _, keep := range []string{ + "192.168.1.5/24 dev tailscale0", + "fe80::1/64 dev tailscale0", + } { + if !slices.Contains(fake.ips, keep) { + t.Errorf("non-Tailscale addr %q was wrongly removed; ips=%q", keep, fake.ips) + } + } +} + +func TestOrphanedAddrs(t *testing.T) { + p := netip.MustParsePrefix + // orphanedAddrs returns set-iteration order, so compare as sets. + got := set.SetOf(orphanedAddrs( + []netip.Prefix{p("100.64.0.1/32"), p("100.64.0.99/32"), p("fd7a:115c:a1e0::99/128")}, + []netip.Prefix{p("100.64.0.1/32")}, + )) + want := set.SetOf([]netip.Prefix{p("100.64.0.99/32"), p("fd7a:115c:a1e0::99/128")}) + if !got.Equal(want) { + t.Errorf("orphanedAddrs = %v; want %v", got.Slice(), want.Slice()) + } + if orphanedAddrs(nil, []netip.Prefix{p("100.64.0.1/32")}) != nil { + t.Error("orphanedAddrs(nil, ...) should be nil") + } +} + +// TestGetV6AvailableNilNFR verifies the v6-availability checks don't panic when +// r.nfr is nil, which happens if setupNetfilterLocked failed earlier in Set. +// The orphan sweep reaches getV6Available via reconcilableTailscaleIP, so this +// must not deref a nil runner. +func TestGetV6AvailableNilNFR(t *testing.T) { + r := &linuxRouter{} // nfr left nil + if r.getV6Available() { + t.Error("getV6Available() = true with nil nfr; want false") + } + if r.getV6FilteringAvailable() { + t.Error("getV6FilteringAvailable() = true with nil nfr; want false") + } + if r.reconcilableTailscaleIP(netip.MustParseAddr("fd7a:115c:a1e0::99")) { + t.Error("reconcilableTailscaleIP(v6) = true with nil nfr; want false") + } +} + +// TestSetSkipsV6OrphansWhenV6Unavailable verifies that when IPv6 is +// unavailable, Set does not enumerate v6 orphans for removal. delAddress +// no-ops on v6 in that state, so enumerating them would let cidrDiff record a +// delete that never happened; instead we leave them out of the reconcile. +func TestSetSkipsV6OrphansWhenV6Unavailable(t *testing.T) { + lr, fake := newTestLinuxRouter(t) + fake.nfr.(*fakeIPTablesRunner).noV6 = true + + fake.ips = []string{ + "100.64.0.99/32 dev tailscale0", // CGNAT v4 orphan, removable + "fd7a:115c:a1e0::99/128 dev tailscale0", // ULA v6 orphan, not removable without v6 + } + slices.Sort(fake.ips) + + cfg := &Config{ + LocalAddrs: mustCIDRs("100.64.0.1/32"), + NetfilterMode: netfilterOff, + } + if err := lr.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + + // The v4 orphan is removed; the v6 orphan is left in place rather than + // being treated as removed via a no-op delete. + if slices.Contains(fake.ips, "100.64.0.99/32 dev tailscale0") { + t.Errorf("v4 orphan was not removed; ips=%q", fake.ips) + } + if !slices.Contains(fake.ips, "fd7a:115c:a1e0::99/128 dev tailscale0") { + t.Errorf("v6 orphan should be left alone when v6 is unavailable; ips=%q", fake.ips) + } +}