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) + } +}