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
4 changed files with 509 additions and 6 deletions
Showing only changes of commit 77d2c87b17 - Show all commits
+14 -2
View File
@@ -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)
}
+33
View File
@@ -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")
}
}
+135 -1
View File
@@ -6,6 +6,7 @@
package osrouter
import (
"bytes"
"errors"
"fmt"
"net"
@@ -27,12 +28,14 @@ import (
"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 <cidr>" or "inet6 <cidr>".
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 {
+327 -3
View File
@@ -31,6 +31,7 @@ import (
"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 "<cidr> 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)
}
}