From bab3f5fce77544937a36d4d7705f040c3cd1cea8 Mon Sep 17 00:00:00 2001 From: Brendan Creane Date: Fri, 17 Jul 2026 14:18:09 -0700 Subject: [PATCH] wgengine/router/osrouter: remove orphaned tailnet addrs on cleanup (#20304) * net/tsaddr: unmap IPv4-mapped IPv6 addrs in IsTailscaleIP IsTailscaleIP branched on ip.Is4() before checking the CGNAT range, so an IPv4-mapped IPv6 address (e.g. ::ffff:100.64.0.1) took the IPv6 path and was tested only against the ULA range, wrongly returning false for a Tailscale CGNAT address. Unmap at the top so both forms are classified identically; Unmap is cheap and IsTailscaleIPv4 stays IPv4-only for callers that need it. Signed-off-by: Brendan Creane * wgengine/router/osrouter: remove orphaned tailnet addrs on cleanup The orphan-address sweep added in #20199 ran only inside Router.Set, so the teardown path (tailscaled --cleanup, and the unconditional cleanup at daemon start) never removed stale Tailscale addresses a previous instance left on a persistent tailscale0 -- it only flushed iptables/nftables. Wire address removal into cleanUp: with no desired config, every Tailscale-range address on the interface is an orphan, so enumerate and delete them all (IPv4 and IPv6, best-effort) in removeOrphanedAddrsForCleanup. tailscaleInterfaceAddrs now yields the interface's addresses as an iter.Seq[netip.Prefix], and the filters compose lazily over it: tailscaleAddrs (every Tailscale-range address; used by cleanup), deletableAddrs (isDeletableAddr: Tailscale-range and deletable now, i.e. excluding v6 when v6 is unavailable; used by the live Set sweep), and orphanedAddrs (drops the desired addresses). The Set sweep ranges the composed iterator directly, so no throwaway slices are built. delAddress is made idempotent: it attempts both the loopback-rule teardown and the address delete and joins their errors, so a missing firewall rule can't leak the address, and it no longer no-ops on v6 (cleanup relies on that to remove v6 orphans even when this process never brought IPv6 up). The Set-time sweep is otherwise unchanged; re-running it on network changes (netmon) for late orphans remains a follow-up (tailscale/corp#43882). Updates #19974 Fixes tailscale/corp#44173 Signed-off-by: Brendan Creane --------- Signed-off-by: Brendan Creane --- net/tsaddr/tsaddr.go | 2 + net/tsaddr/tsaddr_test.go | 16 ++ wgengine/router/osrouter/router_linux.go | 169 ++++++++++++------ wgengine/router/osrouter/router_linux_test.go | 163 ++++++++++++++--- 4 files changed, 275 insertions(+), 75 deletions(-) diff --git a/net/tsaddr/tsaddr.go b/net/tsaddr/tsaddr.go index e77d8a4f5..52f9d4d11 100644 --- a/net/tsaddr/tsaddr.go +++ b/net/tsaddr/tsaddr.go @@ -70,6 +70,7 @@ func TailscaleServiceIPv6() netip.Addr { // IsTailscaleIP reports whether IP is an IP address in a range that // Tailscale assigns from. func IsTailscaleIP(ip netip.Addr) bool { + ip = ip.Unmap() if ip.Is4() { return IsTailscaleIPv4(ip) } @@ -78,6 +79,7 @@ func IsTailscaleIP(ip netip.Addr) bool { // IsTailscaleIPv4 reports whether an IPv4 IP is an IP address that // Tailscale assigns from. +// It will always return false if ip is an "IPv4-mapped IPv6 address". func IsTailscaleIPv4(ip netip.Addr) bool { return CGNATRange().Contains(ip) && !ChromeOSVMRange().Contains(ip) } diff --git a/net/tsaddr/tsaddr_test.go b/net/tsaddr/tsaddr_test.go index 524d5a65b..fa88f3351 100644 --- a/net/tsaddr/tsaddr_test.go +++ b/net/tsaddr/tsaddr_test.go @@ -250,6 +250,12 @@ func TestIsTailscaleIPv4(t *testing.T) { in: netip.MustParseAddr("100.115.92.157"), want: false, }, + { + // IsTailscaleIPv4 does not unmap, so an IPv4-mapped IPv6 form of a + // CGNAT address is not an IPv4 address and must return false. + in: netip.AddrFrom16(netip.MustParseAddr("100.67.19.57").As16()), + want: false, + }, } for _, tt := range tests { if got := IsTailscaleIPv4(tt.in); got != tt.want { @@ -284,6 +290,16 @@ func TestIsTailscaleIP(t *testing.T) { in: netip.MustParseAddr("100.115.92.157"), want: false, }, + { + // IPv4-mapped IPv6 form of a CGNAT address is still a Tailscale IP. + in: netip.MustParseAddr("::ffff:100.67.19.57"), + want: true, + }, + { + // IPv4-mapped IPv6 form of a non-Tailscale address. + in: netip.MustParseAddr("::ffff:10.10.10.10"), + want: false, + }, } for _, tt := range tests { if got := IsTailscaleIP(tt.in); got != tt.want { diff --git a/wgengine/router/osrouter/router_linux.go b/wgengine/router/osrouter/router_linux.go index 3e3998850..c8b31d00e 100644 --- a/wgengine/router/osrouter/router_linux.go +++ b/wgengine/router/osrouter/router_linux.go @@ -10,11 +10,13 @@ "errors" "fmt" "io" + "iter" "net" "net/netip" "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -491,13 +493,13 @@ func (r *linuxRouter) Set(cfg *router.Config) error { // 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 { + if ifaceAddrs, 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 { + kernelAddrs := r.deletableAddrs(ifaceAddrs) + var removed []netip.Prefix + for p := range orphanedAddrs(kernelAddrs, cfg.LocalAddrs) { if prevAddrs[p] { continue // an address we were tracking; cidrDiff already handled it } @@ -1012,37 +1014,45 @@ func (r *linuxRouter) addAddress(addr netip.Prefix) error { return nil } -// delAddress removes an IP/mask from the tunnel interface. Fails if -// the address is not assigned to the interface, or if the removal -// fails. +// delAddress removes an IP/mask from the tunnel interface. It attempts both the +// loopback-rule teardown and the address deletion even if the former fails, so a +// missing firewall rule can't leak the address; errors from both are joined. func (r *linuxRouter) delAddress(addr netip.Prefix) error { - if !r.getV6Available() && addr.Addr().Is6() { - return nil - } + var errs []error if err := r.delLoopbackRule(addr.Addr()); err != nil { - return err + errs = append(errs, err) } + if err := r.delAddrRaw(addr); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +// delAddrRaw deletes addr from the tunnel interface without the loopback-rule +// teardown that [linuxRouter.delAddress] does. +func (r *linuxRouter) delAddrRaw(addr netip.Prefix) error { if r.useIPCommand() { if err := r.cmd.run("ip", "addr", "del", addr.String(), "dev", r.tunname); err != nil { return fmt.Errorf("deleting address %q from tunnel interface: %w", addr, err) } - } else { - link, err := r.link() - if err != nil { - return fmt.Errorf("deleting address %v, %w", addr, err) - } - if err := netlink.AddrDel(link, nlAddrOfPrefix(addr)); err != nil { - return fmt.Errorf("deleting address %v from tunnel interface: %w", addr, err) - } + return nil + } + link, err := r.link() + if err != nil { + return fmt.Errorf("deleting address %v, %w", addr, err) + } + if err := netlink.AddrDel(link, nlAddrOfPrefix(addr)); err != nil { + return fmt.Errorf("deleting address %v from tunnel interface: %w", addr, err) } 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 { +// isDeletableAddr reports whether ip, found on the tunnel interface, is a +// Tailscale-range address the live router should delete. It excludes IPv6 when +// IPv6 is unavailable so the Set-time sweep doesn't churn on addresses this +// instance couldn't have installed; the teardown path uses [tailscaleAddrs] +// instead and removes every Tailscale-range address. +func (r *linuxRouter) isDeletableAddr(ip netip.Addr) bool { ip = ip.Unmap() if !tsaddr.IsTailscaleIP(ip) { return false @@ -1050,11 +1060,34 @@ func (r *linuxRouter) reconcilableTailscaleIP(ip netip.Addr) bool { 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) { +// tailscaleAddrs yields only the Tailscale-range addresses from addrs, per +// [tsaddr.IsTailscaleIP]. +func tailscaleAddrs(addrs iter.Seq[netip.Prefix]) iter.Seq[netip.Prefix] { + return func(yield func(netip.Prefix) bool) { + for p := range addrs { + if tsaddr.IsTailscaleIP(p.Addr()) && !yield(p) { + return + } + } + } +} + +// deletableAddrs yields the addresses from addrs the live router should delete, +// per [linuxRouter.isDeletableAddr]. +func (r *linuxRouter) deletableAddrs(addrs iter.Seq[netip.Prefix]) iter.Seq[netip.Prefix] { + return func(yield func(netip.Prefix) bool) { + for p := range addrs { + if r.isDeletableAddr(p.Addr()) && !yield(p) { + return + } + } + } +} + +// tailscaleInterfaceAddrs yields the addresses on the tunnel interface, +// preserving each kernel prefix length so a later delete matches. It errors if +// the interface can't be read. +func (r *linuxRouter) tailscaleInterfaceAddrs() (iter.Seq[netip.Prefix], error) { if r.useIPCommand() { return r.tailscaleInterfaceAddrsIPCommand() } @@ -1071,22 +1104,17 @@ func (r *linuxRouter) tailscaleInterfaceAddrs() ([]netip.Prefix, error) { 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()) { + if pfx, ok := netipx.FromStdIPNet(a.IPNet); ok { ret = append(ret, pfx) } } - return ret, nil + return slices.Values(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) { +// [linuxRouter.tailscaleInterfaceAddrs], used in tests and when +// TS_DEBUG_USE_IP_COMMAND is set. +func (r *linuxRouter) tailscaleInterfaceAddrsIPCommand() (iter.Seq[netip.Prefix], error) { out, err := r.cmd.output("ip", "-oneline", "addr", "show", "dev", r.tunname) if err != nil { return nil, err @@ -1104,24 +1132,24 @@ func (r *linuxRouter) tailscaleInterfaceAddrsIPCommand() ([]netip.Prefix, error) if err != nil { break } - if r.reconcilableTailscaleIP(p.Addr()) { - ret = append(ret, p) - } + ret = append(ret, p) break } } - return ret, nil + return slices.Values(ret), nil } -// orphanedAddrs returns the addresses in kernelAddrs that are not in desired, +// orphanedAddrs yields 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 +func orphanedAddrs(kernelAddrs iter.Seq[netip.Prefix], desired []netip.Prefix) iter.Seq[netip.Prefix] { + want := set.SetOf(desired) + return func(yield func(netip.Prefix) bool) { + for p := range kernelAddrs { + if !want.Contains(p) && !yield(p) { + return + } + } } - s := set.SetOf(kernelAddrs) - s.DeleteSlice(desired) - return s.Slice() } // addLoopbackRule adds a firewall rule to permit loopback traffic to @@ -1910,10 +1938,49 @@ func platformCanNetfilter() bool { // The function calls cleanUp for both iptables and nftables since which ever // netfilter runner is used, the cleanUp function for the other one doesn't do anything. func cleanUp(logf logger.Logf, interfaceName string) { - if interfaceName != "userspace-networking" && platformCanNetfilter() { + if interfaceName == "userspace-networking" { + return + } + if platformCanNetfilter() { linuxfw.IPTablesCleanUp(logf) linuxfw.NfTablesCleanUp(logf) } + removeOrphanedAddrsForCleanup(logf, osCommandRunner{ambientCapNetAdmin: useAmbientCaps()}, interfaceName) +} + +// removeOrphanedAddrsForCleanup removes every Tailscale-range address from +// interfaceName. On the teardown path (tailscaled --cleanup, and the cleanup at +// daemon start) there is no desired config or running router, so every such +// address is an orphan. Best-effort: failures are logged, not returned. +func removeOrphanedAddrsForCleanup(logf logger.Logf, cmd commandRunner, interfaceName string) { + // A minimal router suffices: delAddress reaches delLoopbackRule, which + // returns early because netfilterMode is netfilterOff (the zero value), so + // the nil nfr/netMon/health are never dereferenced. logf is wrapped to match + // the live router's "router: " prefix. + r := &linuxRouter{ + logf: logger.WithPrefix(logf, "router: "), + tunname: interfaceName, + cmd: cmd, + } + ifaceAddrs, err := r.tailscaleInterfaceAddrs() + if err != nil { + r.logf("enumerating %s addresses for cleanup failed: %v", interfaceName, err) + return + } + // Unlike the live sweep's deletableAddrs, tailscaleAddrs keeps IPv6 too: a + // previous instance may have left a ULA orphan even though this process never + // brought IPv6 up. delAddress is idempotent, so a no-op v6 delete is harmless. + var removed []netip.Prefix + for p := range tailscaleAddrs(ifaceAddrs) { + if err := r.delAddress(p); err != nil { + r.logf("removing stale address %v from %s during cleanup failed: %v", p, interfaceName, err) + continue + } + removed = append(removed, p) + } + if len(removed) > 0 { + r.logf("removed %d stale Tailscale address(es) from %s during cleanup: %v", len(removed), interfaceName, removed) + } } // Checks if the running openWRT system is using mwan3, based on the heuristic diff --git a/wgengine/router/osrouter/router_linux_test.go b/wgengine/router/osrouter/router_linux_test.go index eff59ada6..efa2bd358 100644 --- a/wgengine/router/osrouter/router_linux_test.go +++ b/wgengine/router/osrouter/router_linux_test.go @@ -1177,13 +1177,18 @@ func (o *fakeOS) run(args ...string) error { func (o *fakeOS) output(args ...string) ([]byte, error) { 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. + if dev, ok := strings.CutPrefix(got, "ip -oneline addr show dev "); ok { + // Render o.ips (entries look like " dev ") in a simplified + // `ip -oneline addr show` format that exposes the family token the parser + // keys off of. Only addresses on the requested device are returned, so the + // fake models a real multi-interface host: `show dev tailscale0` never + // reports an address that lives on eth0. var ret []string for _, e := range o.ips { - cidr, _, _ := strings.Cut(e, " ") + cidr, rest, _ := strings.Cut(e, " ") + if rest != "dev "+dev { + continue // address is on a different interface + } p, err := netip.ParsePrefix(cidr) if err != nil { continue @@ -1192,7 +1197,7 @@ func (o *fakeOS) output(args ...string) ([]byte, error) { if p.Addr().Is6() { fam = "inet6" } - ret = append(ret, fmt.Sprintf("3: tailscale0 %s %s scope global tailscale0", fam, cidr)) + ret = append(ret, fmt.Sprintf("3: %s %s %s scope global %s", dev, fam, cidr, dev)) } return []byte(strings.Join(ret, "\n")), nil } @@ -1855,10 +1860,15 @@ func TestSetOrphanScanNotRetriedOnDuplicateLocalAddrs(t *testing.T) { 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 + keep := []string{ + "192.168.1.5/24 dev tailscale0", // non-Tailscale v4 + "fe80::1/64 dev tailscale0", // link-local v6 + "100.115.92.5/32 dev tailscale0", // ChromeOS VM range: in CGNAT, not a Tailscale IP + "100.63.0.1/32 dev tailscale0", // just below CGNAT 100.64.0.0/10 + "100.128.0.1/32 dev tailscale0", // just above CGNAT + "fd7a:115c:a1e1::1/128 dev tailscale0", // adjacent to the Tailscale ULA /48, not in it } + fake.ips = append([]string(nil), keep...) slices.Sort(fake.ips) cfg := &Config{ @@ -1869,35 +1879,32 @@ func TestSetKeepsNonTailscaleAddrs(t *testing.T) { 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) + for _, k := range keep { + if !slices.Contains(fake.ips, k) { + t.Errorf("non-Tailscale addr %q was wrongly removed; ips=%q", k, 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")}, + // orphanedAddrs yields in kernelAddrs order, but compare as sets to be safe. + got := set.SetOf(slices.Collect(orphanedAddrs( + slices.Values([]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") + if got := slices.Collect(orphanedAddrs(slices.Values([]netip.Prefix(nil)), []netip.Prefix{p("100.64.0.1/32")})); got != nil { + t.Errorf("orphanedAddrs(nil, ...) = %v; want nil", got) } } // 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 +// The orphan sweep reaches getV6Available via isDeletableAddr, so this // must not deref a nil runner. func TestGetV6AvailableNilNFR(t *testing.T) { r := &linuxRouter{} // nfr left nil @@ -1907,8 +1914,8 @@ func TestGetV6AvailableNilNFR(t *testing.T) { 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") + if r.isDeletableAddr(netip.MustParseAddr("fd7a:115c:a1e0::99")) { + t.Error("isDeletableAddr(v6) = true with nil nfr; want false") } } @@ -1997,3 +2004,111 @@ func TestSetSnapshotsV6Usable(t *testing.T) { t.Errorf("interfaceV6Usable called %d times during one Set; want 1 (snapshotted)", calls) } } + +// TestCleanUpRemovesAllTailscaleAddrs verifies the teardown path removes every +// Tailscale-range address (IPv4 CGNAT and IPv6 ULA) while leaving non-Tailscale +// addresses alone. +func TestCleanUpRemovesAllTailscaleAddrs(t *testing.T) { + fake := NewFakeOS(t) + fake.ips = []string{ + "100.64.0.1/32 dev tailscale0", // CGNAT v4 + "fd7a:115c:a1e0::1/128 dev tailscale0", // ULA v6 + "192.168.1.5/24 dev tailscale0", // non-Tailscale, leave alone + "fe80::1/64 dev tailscale0", // link-local v6, leave alone + } + slices.Sort(fake.ips) + + removeOrphanedAddrsForCleanup(t.Logf, fake, "tailscale0") + + for _, gone := range []string{ + "100.64.0.1/32 dev tailscale0", + "fd7a:115c:a1e0::1/128 dev tailscale0", + } { + if slices.Contains(fake.ips, gone) { + t.Errorf("Tailscale addr %q was not removed during cleanup; ips=%q", gone, fake.ips) + } + } + 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 during cleanup; ips=%q", keep, fake.ips) + } + } +} + +// TestCleanUpRemovesV6OrphanWithoutNetfilter is the teardown counterpart to +// TestSetSkipsV6OrphansWhenV6Unavailable: with a nil netfilter runner (so +// getV6Available is false), cleanup must still remove an IPv6 ULA orphan. +func TestCleanUpRemovesV6OrphanWithoutNetfilter(t *testing.T) { + fake := NewFakeOS(t) + fake.ips = []string{ + "fd7a:115c:a1e0::99/128 dev tailscale0", // ULA v6 orphan + } + + removeOrphanedAddrsForCleanup(t.Logf, fake, "tailscale0") + + if slices.Contains(fake.ips, "fd7a:115c:a1e0::99/128 dev tailscale0") { + t.Errorf("v6 orphan was not removed during cleanup; ips=%q", fake.ips) + } +} + +// TestCleanUpKeepsNearRangeAddrs verifies the teardown sweep removes real +// Tailscale orphans while leaving addresses at the edges of Tailscale's ranges +// untouched. In particular the ChromeOS VM range is inside CGNAT 100.64.0.0/10 +// but excluded by tsaddr.IsTailscaleIP, so a naive CGNAT-prefix check would +// wrongly delete it. +func TestCleanUpKeepsNearRangeAddrs(t *testing.T) { + fake := NewFakeOS(t) + remove := []string{ + "100.64.0.99/32 dev tailscale0", // CGNAT v4 orphan + "fd7a:115c:a1e0::99/128 dev tailscale0", // Tailscale ULA v6 orphan + } + keep := []string{ + "100.115.92.5/32 dev tailscale0", // ChromeOS VM range: in CGNAT, not a Tailscale IP + "100.63.0.1/32 dev tailscale0", // just below CGNAT + "100.128.0.1/32 dev tailscale0", // just above CGNAT + "fd7a:115c:a1e1::1/128 dev tailscale0", // adjacent to the Tailscale ULA /48 + "192.168.1.5/24 dev tailscale0", // non-Tailscale v4 + "fe80::1/64 dev tailscale0", // link-local v6 + } + fake.ips = append(append([]string(nil), remove...), keep...) + slices.Sort(fake.ips) + + removeOrphanedAddrsForCleanup(t.Logf, fake, "tailscale0") + + for _, r := range remove { + if slices.Contains(fake.ips, r) { + t.Errorf("orphan %q was not removed during cleanup; ips=%q", r, fake.ips) + } + } + for _, k := range keep { + if !slices.Contains(fake.ips, k) { + t.Errorf("near-range non-Tailscale addr %q was wrongly removed during cleanup; ips=%q", k, fake.ips) + } + } +} + +// TestCleanUpOnlyTouchesTunInterface guards that the sweep is scoped to the +// tunnel interface. 100.64.0.0/10 is shared ISP CGNAT space, so a host may carry +// a non-Tailscale CGNAT address on its WAN/other interface; the sweep enumerates +// and deletes only on tailscale0, so such an address on eth0 must never be +// touched even though it's in the CGNAT range. +func TestCleanUpOnlyTouchesTunInterface(t *testing.T) { + fake := NewFakeOS(t) + fake.ips = []string{ + "100.64.0.99/32 dev tailscale0", // our orphan on the tun -> removed + "100.64.0.5/32 dev eth0", // someone else's CGNAT on WAN -> must survive + } + slices.Sort(fake.ips) + + removeOrphanedAddrsForCleanup(t.Logf, fake, "tailscale0") + + if slices.Contains(fake.ips, "100.64.0.99/32 dev tailscale0") { + t.Errorf("tailscale0 orphan was not removed; ips=%q", fake.ips) + } + if !slices.Contains(fake.ips, "100.64.0.5/32 dev eth0") { + t.Errorf("non-Tailscale CGNAT addr on eth0 was wrongly removed; ips=%q", fake.ips) + } +}