// Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause package routemanager import ( "net/netip" "reflect" "slices" "testing" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/util/set" ) var ( k1 = key.NewNode().Public() k2 = key.NewNode().Public() k3 = key.NewNode().Public() ) func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) } func addr(s string) netip.Addr { return netip.MustParseAddr(s) } // peer1 is a basic peer with self addresses only. func peer1() peerView { return peerView{ ID: 1, Key: k1, SelfAddrs: []netip.Prefix{ pfx("100.64.0.1/32"), pfx("fd7a:115c:a1e0::1/128"), }, } } func peer2() peerView { return peerView{ ID: 2, Key: k2, SelfAddrs: []netip.Prefix{ pfx("100.64.0.2/32"), pfx("fd7a:115c:a1e0::2/128"), }, } } // commit applies fn within a single mutation and returns the Result. func commit(rm *RouteManager, fn func(*Mutation)) Result { m := rm.Begin() fn(m) return m.Commit() } func wantOutbound(t *testing.T, rm *RouteManager, ip string, want key.NodePublic, wantOK bool) { t.Helper() got, ok := rm.Outbound().Lookup(addr(ip)) if ok != wantOK || (ok && got.Key != want) { var gotKey key.NodePublic if got != nil { gotKey = got.Key } t.Errorf("Outbound lookup %s = %v, %v; want %v, %v", ip, gotKey.ShortString(), ok, want.ShortString(), wantOK) } } func wantOSRoutes(t *testing.T, rm *RouteManager, want ...string) { t.Helper() wantSet := make(set.Set[netip.Prefix]) for _, s := range want { wantSet.Add(pfx(s)) } got := make(set.Set[netip.Prefix]) for p := range rm.OSRoutes().All() { got.Add(p) } if !got.Equal(wantSet) { t.Errorf("OSRoutes = %v; want %v", got.Slice(), wantSet.Slice()) } } func TestSelfAddrs(t *testing.T) { rm := New(t.Logf) res := commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) if res.PeersUpserted != 1 { t.Errorf("PeersUpserted = %d; want 1", res.PeersUpserted) } if !res.OutboundChanged || !res.OSRoutesChanged { t.Errorf("changed flags = %v/%v; want all true", res.OutboundChanged, res.OSRoutesChanged) } if res.Outbound == nil || res.OSRoutes == nil { t.Error("Result snapshot pointers should be non-nil when changed") } wantOutbound(t, rm, "100.64.0.1", k1, true) wantOutbound(t, rm, "fd7a:115c:a1e0::1", k1, true) wantOutbound(t, rm, "100.64.0.2", key.NodePublic{}, false) // Individual CGNAT /32 (below threshold) plus the coarse ULA range. wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") } func TestRemovePeer(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) res := commit(rm, func(m *Mutation) { m.RemovePeer(1) }) if res.PeersRemoved != 1 { t.Errorf("PeersRemoved = %d; want 1", res.PeersRemoved) } wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) wantOSRoutes(t, rm) if rm.ForTest().PeerCount() != 0 { t.Errorf("PeerCount = %d; want 0", rm.ForTest().PeerCount()) } // Removing an unknown peer is a no-op. res = commit(rm, func(m *Mutation) { m.RemovePeer(42) }) if res.PeersRemoved != 0 || res.OutboundChanged { t.Errorf("remove of unknown peer: %+v", res) } } func TestExitNodeGating(t *testing.T) { rm := New(t.Logf) exitPeer := peer1() exitPeer.Routes = tsaddr.ExitRoutes() commit(rm, func(m *Mutation) { m.upsertPeer(exitPeer) }) // Not selected: no /0 entries anywhere. wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") // Select it as exit node. res := commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{ExitNodeID: 1}) }) if !res.PrefsChanged || !res.OutboundChanged || !res.OSRoutesChanged { t.Errorf("select exit node: %+v", res) } wantOutbound(t, rm, "8.8.8.8", k1, true) wantOutbound(t, rm, "2001:db8::1", k1, true) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0") // Deselect: /0s vanish. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) }) wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") // Selecting a peer that doesn't advertise /0s adds nothing. commit(rm, func(m *Mutation) { m.upsertPeer(peer2()) m.SetPrefs(Prefs{ExitNodeID: 2}) }) wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) } func TestRouteAll(t *testing.T) { rm := New(t.Logf) p := peer1() p.Routes = []netip.Prefix{pfx("10.0.0.0/24")} commit(rm, func(m *Mutation) { m.upsertPeer(p) }) // Subnet routes ignored until RouteAll. wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) wantOutbound(t, rm, "10.0.0.5", k1, true) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "10.0.0.0/24") commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) }) wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) } func TestSharedSubnetRoute(t *testing.T) { rm := New(t.Logf) route := pfx("10.0.0.0/24") pa, pb := peer1(), peer2() pa.Routes = []netip.Prefix{route} pb.Routes = []netip.Prefix{route} commit(rm, func(m *Mutation) { m.upsertPeer(pa) m.upsertPeer(pb) m.SetPrefs(Prefs{RouteAll: true}) }) // Tie on score: lowest NodeID wins outbound. wantOutbound(t, rm, "10.0.0.5", k1, true) // Raise peer 2's score: outbound flips, OS routes unchanged. res := commit(rm, func(m *Mutation) { m.SetScore(2, route, 100) }) if res.ScoresChanged != 1 || !res.OutboundChanged || res.OSRoutesChanged { t.Errorf("score change: %+v", res) } wantOutbound(t, rm, "10.0.0.5", k2, true) // Setting the same score again is a no-op. res = commit(rm, func(m *Mutation) { m.SetScore(2, route, 100) }) if res.ScoresChanged != 0 || res.OutboundChanged { t.Errorf("same score: %+v", res) } // Resetting the score to zero restores the NodeID tie-break. res = commit(rm, func(m *Mutation) { m.SetScore(2, route, 0) }) if res.ScoresChanged != 1 || !res.OutboundChanged { t.Errorf("score reset: %+v", res) } wantOutbound(t, rm, "10.0.0.5", k1, true) // Resetting an already-absent score is a no-op. res = commit(rm, func(m *Mutation) { m.SetScore(2, route, 0) }) if res.ScoresChanged != 0 { t.Errorf("absent score reset: %+v", res) } // Restore peer 2 as winner for the removal test below. commit(rm, func(m *Mutation) { m.SetScore(2, route, 100) }) wantOutbound(t, rm, "10.0.0.5", k2, true) // Removing the winner falls back to the other advertiser. commit(rm, func(m *Mutation) { m.RemovePeer(2) }) wantOutbound(t, rm, "10.0.0.5", k1, true) // Peer 2's score was dropped on removal: re-adding it ties again // and peer 1 wins by lower NodeID. commit(rm, func(m *Mutation) { m.upsertPeer(pb) }) wantOutbound(t, rm, "10.0.0.5", k1, true) } func TestOneCGNAT(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.SetTailnetConfig(TailnetConfig{OneCGNAT: true}) m.upsertPeer(peer1()) }) // One CGNAT addr: not above threshold (1 > 1 is false), so // still individual, mirroring ipnlocal.peerRoutes. wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") // Second CGNAT addr crosses the threshold: collapse to /10. res := commit(rm, func(m *Mutation) { m.upsertPeer(peer2()) }) if !res.OSRoutesChanged { t.Errorf("crossing threshold: %+v", res) } wantOSRoutes(t, rm, "100.64.0.0/10", "fd7a:115c:a1e0::/48") // Hot-path tables stay exact regardless of OS coarsening. wantOutbound(t, rm, "100.64.0.2", k2, true) wantOutbound(t, rm, "100.64.0.3", key.NodePublic{}, false) // Dropping back below the threshold un-coarsens. commit(rm, func(m *Mutation) { m.RemovePeer(2) }) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") } // TestFullRebuildWhileCoarse exercises the full-rebuild path (a prefs // change) while CGNAT coarsening is already active. func TestFullRebuildWhileCoarse(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.SetTailnetConfig(TailnetConfig{OneCGNAT: true}) m.upsertPeer(peer1()) m.upsertPeer(peer2()) }) wantOSRoutes(t, rm, "100.64.0.0/10", "fd7a:115c:a1e0::/48") res := commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) if !res.PrefsChanged || res.OSRoutesChanged { t.Errorf("prefs change while coarse: %+v", res) } wantOSRoutes(t, rm, "100.64.0.0/10", "fd7a:115c:a1e0::/48") wantOutbound(t, rm, "100.64.0.2", k2, true) } // TestSingleIPPlainRoute checks that a single-IP route outside the // CGNAT and ULA ranges is programmed individually, not coarsened. func TestSingleIPPlainRoute(t *testing.T) { rm := New(t.Logf) p := peer1() p.Routes = []netip.Prefix{pfx("192.0.2.7/32")} commit(rm, func(m *Mutation) { m.upsertPeer(p) m.SetPrefs(Prefs{RouteAll: true}) }) wantOutbound(t, rm, "192.0.2.7", k1, true) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "192.0.2.7/32") } func TestDisableIPv4(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.SetTailnetConfig(TailnetConfig{DisableIPv4: true}) m.upsertPeer(peer1()) }) wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) wantOutbound(t, rm, "fd7a:115c:a1e0::1", k1, true) wantOSRoutes(t, rm, "fd7a:115c:a1e0::/48") } func TestKeyRotation(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) rotated := peer1() rotated.Key = k3 res := commit(rm, func(m *Mutation) { m.upsertPeer(rotated) }) if !res.OutboundChanged || res.OSRoutesChanged { t.Errorf("key rotation: %+v", res) } wantOutbound(t, rm, "100.64.0.1", k3, true) } // TestPeerRouteAttrs checks that the data-plane attributes (jailed, // masquerade addresses) are published in the outbound table, that // changing only an attribute republishes the peer's prefixes, and // that previously published snapshots keep the old attributes. func TestPeerRouteAttrs(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) pr, ok := rm.Outbound().Lookup(addr("100.64.0.1")) if !ok || pr.Jailed || pr.MasqAddr4.IsValid() || pr.MasqAddr6.IsValid() { t.Fatalf("initial attrs = %+v, %v; want unjailed, no masq", pr, ok) } if rm.HasDataPlaneAttrs() { t.Error("HasDataPlaneAttrs = true with no jailed or masqueraded peers") } oldOut := rm.Outbound() jailed := peer1() jailed.Jailed = true jailed.MasqAddr4 = addr("100.99.0.1") res := commit(rm, func(m *Mutation) { m.upsertPeer(jailed) }) if !res.OutboundChanged || res.OSRoutesChanged { t.Errorf("attr change: %+v", res) } if res.AllowedIPs != nil { t.Errorf("attr change reported AllowedIPs %v; attrs must not affect allowed prefixes", res.AllowedIPs) } pr, ok = rm.Outbound().Lookup(addr("100.64.0.1")) if !ok || !pr.Jailed || pr.MasqAddr4 != addr("100.99.0.1") || pr.Key != k1 { t.Fatalf("updated attrs = %+v, %v", pr, ok) } if !rm.HasDataPlaneAttrs() { t.Error("HasDataPlaneAttrs = false with a jailed peer") } if pr, ok := oldOut.Lookup(addr("100.64.0.1")); !ok || pr.Jailed { t.Errorf("old snapshot attrs = %+v, %v; want original unjailed entry", pr, ok) } // An identical re-upsert is a no-op. res = commit(rm, func(m *Mutation) { m.upsertPeer(jailed) }) if res.OutboundChanged { t.Errorf("identical attrs re-upsert changed outbound: %+v", res) } if !rm.HasDataPlaneAttrs() { t.Error("HasDataPlaneAttrs = false after identical re-upsert of a jailed peer") } // Removing the peer drops its data-plane attributes. commit(rm, func(m *Mutation) { m.RemovePeer(1) }) if rm.HasDataPlaneAttrs() { t.Error("HasDataPlaneAttrs = true after removing the only jailed peer") } } func TestNoopCommit(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) res := commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) if res.OutboundChanged || res.OSRoutesChanged { t.Errorf("identical re-upsert changed tables: %+v", res) } if res.Outbound != nil || res.OSRoutes != nil { t.Error("Result snapshot pointers should be nil when unchanged") } if res.PeersUpserted != 1 { t.Errorf("PeersUpserted = %d; want 1", res.PeersUpserted) } // Same prefs again: no rebuild reported. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) if res.PrefsChanged { t.Errorf("identical prefs reported changed: %+v", res) } // An empty mutation commits fine. res = commit(rm, func(m *Mutation) {}) if !reflect.DeepEqual(res, Result{}) { t.Errorf("empty commit: %+v", res) } } func TestUnmaskedPrefixSkipped(t *testing.T) { rm := New(t.Logf) p := peer1() p.Routes = []netip.Prefix{netip.PrefixFrom(addr("10.0.0.5"), 24)} // non-address bits set commit(rm, func(m *Mutation) { m.upsertPeer(p) m.SetPrefs(Prefs{RouteAll: true}) }) wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) } func TestSnapshotImmutability(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) oldOut := rm.Outbound() commit(rm, func(m *Mutation) { m.RemovePeer(1) }) // The old snapshot still sees the removed peer. if _, ok := oldOut.Lookup(addr("100.64.0.1")); !ok { t.Error("old outbound snapshot lost entry after later commit") } // And the new one doesn't. wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) } func TestDiscard(t *testing.T) { rm := New(t.Logf) m := rm.Begin() m.upsertPeer(peer1()) m.Discard() if rm.ForTest().PeerCount() != 0 { t.Error("discarded mutation was applied") } // A new mutation works after a discard. commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) if rm.ForTest().PeerCount() != 1 { t.Error("commit after discard failed") } } func TestConcurrentMutationPanics(t *testing.T) { rm := New(t.Logf) m1 := rm.Begin() m2 := rm.Begin() m1.upsertPeer(peer1()) m1.Commit() defer func() { if recover() == nil { t.Error("overlapping Commit did not panic") } }() m2.Commit() } func TestFinishedMutationPanics(t *testing.T) { rm := New(t.Logf) m := rm.Begin() m.Commit() defer func() { if recover() == nil { t.Error("op on finished Mutation did not panic") } }() m.upsertPeer(peer1()) } func TestPeerModifyRoutes(t *testing.T) { rm := New(t.Logf) p := peer1() p.Routes = []netip.Prefix{pfx("10.0.0.0/24")} commit(rm, func(m *Mutation) { m.upsertPeer(p) m.SetPrefs(Prefs{RouteAll: true}) }) wantOutbound(t, rm, "10.0.0.5", k1, true) // Swap the advertised route for another. p.Routes = []netip.Prefix{pfx("192.168.1.0/24")} res := commit(rm, func(m *Mutation) { m.upsertPeer(p) }) if !res.OutboundChanged { t.Errorf("route swap: %+v", res) } wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) wantOutbound(t, rm, "192.168.1.5", k1, true) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "192.168.1.0/24") } // BenchmarkUpsertOnePeer measures the cost of a single-peer mutation // against a manager already tracking many peers, the case the // incremental design exists for. func BenchmarkUpsertOnePeer(b *testing.B) { rm := New(nil) m := rm.Begin() for i := range 10_000 { id := tailcfg.NodeID(i + 1) m.upsertPeer(peerView{ ID: id, Key: key.NewNode().Public(), SelfAddrs: []netip.Prefix{ netip.PrefixFrom(netip.AddrFrom4([4]byte{100, 64, byte(i >> 8), byte(i)}), 32), }, }) } m.Commit() p := peerView{ ID: 99_999, Key: key.NewNode().Public(), SelfAddrs: []netip.Prefix{pfx("100.100.1.1/32")}, } b.ReportAllocs() for i := 0; b.Loop(); i++ { p.SelfAddrs[0] = netip.PrefixFrom(netip.AddrFrom4([4]byte{100, 100, byte(i >> 8), byte(i)}), 32) m := rm.Begin() m.upsertPeer(p) m.Commit() } } func TestUpsertPeerNodeView(t *testing.T) { rm := New(t.Logf) n := &tailcfg.Node{ ID: 1, Key: k1, HomeDERP: 1, Addresses: []netip.Prefix{ pfx("100.64.0.1/32"), pfx("fd7a:115c:a1e0::1/128"), }, AllowedIPs: []netip.Prefix{ pfx("100.64.0.1/32"), pfx("fd7a:115c:a1e0::1/128"), pfx("10.0.0.0/24"), pfx("0.0.0.0/0"), pfx("::/0"), }, IsJailed: true, SelfNodeV4MasqAddrForThisPeer: new(addr("100.99.0.5")), } commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) // The data-plane attributes are carried through from the node. if pr, ok := rm.Outbound().Lookup(addr("100.64.0.1")); !ok || !pr.Jailed || pr.MasqAddr4 != addr("100.99.0.5") || pr.MasqAddr6.IsValid() { t.Errorf("attrs = %+v, %v; want jailed with v4 masq only", pr, ok) } // Self addresses are live immediately; the subnet route and the // exit routes were classified as routes and stay gated by prefs. wantOutbound(t, rm, "100.64.0.1", k1, true) wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true, ExitNodeID: 1}) }) wantOutbound(t, rm, "10.0.0.5", k1, true) wantOutbound(t, rm, "8.8.8.8", k1, true) } // TestUpsertPeerNodeViewVIPService checks that a single Tailscale IP // in a peer's AllowedIPs but not in its Addresses (such as a VIP // service address hosted by the peer) is classified as a self address // and stays routable without Prefs.RouteAll, mirroring nmcfg's // cidrIsSubnet. func TestUpsertPeerNodeViewVIPService(t *testing.T) { rm := New(t.Logf) n := &tailcfg.Node{ ID: 1, Key: k1, HomeDERP: 1, Addresses: []netip.Prefix{ pfx("100.64.0.1/32"), }, AllowedIPs: []netip.Prefix{ pfx("100.64.0.1/32"), pfx("100.100.5.5/32"), // VIP service address pfx("192.168.1.99/32"), // single non-Tailscale IP: a subnet route pfx("10.0.0.0/24"), }, } commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) // With RouteAll off, the node address and the VIP service address // are routable, but the subnet routes are not. wantOutbound(t, rm, "100.64.0.1", k1, true) wantOutbound(t, rm, "100.100.5.5", k1, true) wantOutbound(t, rm, "192.168.1.99", key.NodePublic{}, false) wantOutbound(t, rm, "10.0.0.5", key.NodePublic{}, false) wantOSRoutes(t, rm, "100.64.0.1/32", "100.100.5.5/32") commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) wantOutbound(t, rm, "192.168.1.99", k1, true) wantOutbound(t, rm, "10.0.0.5", k1, true) } // TestUpsertPeerNodeViewEmptyAllowedIPs checks that AllowedIPs is // the sole source of prefixes: a peer whose AllowedIPs is empty is // not routable even if it has addresses, matching nmcfg.WGCfg. func TestUpsertPeerNodeViewEmptyAllowedIPs(t *testing.T) { rm := New(t.Logf) n := &tailcfg.Node{ ID: 1, Key: k1, HomeDERP: 1, Addresses: []netip.Prefix{pfx("100.64.0.1/32")}, } commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) wantOSRoutes(t, rm) if _, ok := rm.PeerAllowedIPs(1); ok { t.Error("PeerAllowedIPs = ok; want !ok for peer with no AllowedIPs") } } // TestUpsertPeerNodeViewIneligible checks that peers we cannot // communicate with (expired, or predating both DERP and disco) are // tracked but contribute no prefixes, mirroring nmcfg.WGCfg's peer // filtering, and that a later update restores them. func TestUpsertPeerNodeViewIneligible(t *testing.T) { base := func() *tailcfg.Node { return &tailcfg.Node{ ID: 1, Key: k1, HomeDERP: 1, Addresses: []netip.Prefix{pfx("100.64.0.1/32")}, AllowedIPs: []netip.Prefix{ pfx("100.64.0.1/32"), }, } } for _, tc := range []struct { name string mod func(*tailcfg.Node) }{ {"expired", func(n *tailcfg.Node) { n.Expired = true }}, {"noDERPOrDisco", func(n *tailcfg.Node) { n.HomeDERP = 0 }}, } { t.Run(tc.name, func(t *testing.T) { rm := New(t.Logf) n := base() tc.mod(n) commit(rm, func(m *Mutation) { m.UpsertPeer(n.View()) }) wantOutbound(t, rm, "100.64.0.1", key.NodePublic{}, false) wantOSRoutes(t, rm) if _, ok := rm.PeerAllowedIPs(1); ok { t.Error("PeerAllowedIPs = ok; want !ok for ineligible peer") } // An update that clears the condition restores the peer. commit(rm, func(m *Mutation) { m.UpsertPeer(base().View()) }) wantOutbound(t, rm, "100.64.0.1", k1, true) }) } } // wantChangedAllowedIPs checks res.AllowedIPs against want, where // want maps each expected key to its expected new prefixes (nil for // "no allowed prefixes"). func wantChangedAllowedIPs(t *testing.T, res Result, want map[key.NodePublic][]string) { t.Helper() wantMap := make(PeersWithRouteChanges) for k, ss := range want { var pfxs []netip.Prefix for _, s := range ss { pfxs = append(pfxs, pfx(s)) } tsaddr.SortPrefixes(pfxs) wantMap[k] = pfxs } if len(want) == 0 { if res.AllowedIPs != nil { t.Errorf("Result.AllowedIPs = %v; want nil", res.AllowedIPs) } return } if !reflect.DeepEqual(res.AllowedIPs, wantMap) { t.Errorf("Result.AllowedIPs = %v; want %v", res.AllowedIPs, wantMap) } } func TestResultAllowedIPs(t *testing.T) { rm := New(t.Logf) // A new peer appears in the map with its prefixes. res := commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128"}, }) // An identical re-upsert reports nothing. res = commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) wantChangedAllowedIPs(t, res, nil) // Add a second peer advertising a subnet route and the exit // routes; with default prefs only its self addresses count. p2 := peer2() p2.Routes = []netip.Prefix{pfx("10.0.0.0/24"), pfx("0.0.0.0/0"), pfx("::/0")} res = commit(rm, func(m *Mutation) { m.upsertPeer(p2) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128"}, }) // RouteAll adds the subnet route to peer 2 only; peer 1 has no // routes, so it does not appear. res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128", "10.0.0.0/24"}, }) // Selecting peer 2 as exit node adds the /0s to it only. res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true, ExitNodeID: 2}) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128", "10.0.0.0/24", "0.0.0.0/0", "::/0"}, }) // Deselecting removes them again. res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k2: {"100.64.0.2/32", "fd7a:115c:a1e0::2/128", "10.0.0.0/24"}, }) // A score change affects only the outbound winner, not any // peer's allowed prefixes. res = commit(rm, func(m *Mutation) { m.SetScore(2, pfx("10.0.0.0/24"), 100) }) wantChangedAllowedIPs(t, res, nil) // A key rotation reports the old key with no prefixes and the // new key with the peer's prefixes. rotated := peer1() rotated.Key = k3 res = commit(rm, func(m *Mutation) { m.upsertPeer(rotated) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k1: nil, k3: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128"}, }) // A removed peer reports its key with no prefixes. res = commit(rm, func(m *Mutation) { m.RemovePeer(2) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k2: nil, }) // A prefs change combined with an upsert of a brand-new peer in // the same commit reports both correctly: peer 1 gains nothing // from RouteAll going away (it has no routes), and the new peer // appears with its prefixes. p4 := peerView{ ID: 4, Key: key.NewNode().Public(), SelfAddrs: []netip.Prefix{pfx("100.64.0.4/32")}, } res = commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) m.upsertPeer(p4) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ p4.Key: {"100.64.0.4/32"}, }) } func TestPeerAllowedIPs(t *testing.T) { rm := New(t.Logf) p := peer1() p.Routes = []netip.Prefix{pfx("10.0.0.0/24"), pfx("0.0.0.0/0"), pfx("::/0")} commit(rm, func(m *Mutation) { m.upsertPeer(p) }) wantAllowed := func(want ...string) { t.Helper() var wantPfxs []netip.Prefix for _, s := range want { wantPfxs = append(wantPfxs, pfx(s)) } tsaddr.SortPrefixes(wantPfxs) got, ok := rm.PeerAllowedIPs(1) if len(want) == 0 { if ok { t.Errorf("PeerAllowedIPs = %v; want !ok", got) } return } if !ok || !slices.Equal(got, wantPfxs) { t.Errorf("PeerAllowedIPs = %v, %v; want %v", got, ok, wantPfxs) } } // Default prefs: self addresses only. wantAllowed("100.64.0.1/32", "fd7a:115c:a1e0::1/128") // RouteAll adds the subnet route but not the exit routes. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) wantAllowed("100.64.0.1/32", "fd7a:115c:a1e0::1/128", "10.0.0.0/24") // Selecting the peer as exit node adds the /0s. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true, ExitNodeID: 1}) }) wantAllowed("100.64.0.1/32", "fd7a:115c:a1e0::1/128", "10.0.0.0/24", "0.0.0.0/0", "::/0") // Unknown peers report !ok. if _, ok := rm.PeerAllowedIPs(42); ok { t.Error("PeerAllowedIPs(42) = ok; want !ok") } // A removed peer reports !ok. commit(rm, func(m *Mutation) { m.RemovePeer(1) }) wantAllowed() } func TestExtraAllowedIPs(t *testing.T) { rm := New(t.Logf) commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) // Extras appear in the outbound table and in PeerAllowedIPs, // but not in the OS route set, even for prefixes (like the // CGNAT one here) whose class would otherwise be coarsened. res := commit(rm, func(m *Mutation) { m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ 1: {pfx("fe80::1234/128"), pfx("100.100.100.100/32")}, }) }) if !res.ExtrasChanged { t.Error("ExtrasChanged = false; want true") } wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128", "fe80::1234/128", "100.100.100.100/32"}, }) wantOutbound(t, rm, "fe80::1234", k1, true) wantOutbound(t, rm, "100.100.100.100", k1, true) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") // An identical re-set is a no-op. res = commit(rm, func(m *Mutation) { m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ 1: {pfx("fe80::1234/128"), pfx("100.100.100.100/32")}, }) }) if res.ExtrasChanged || res.OutboundChanged || res.AllowedIPs != nil { t.Errorf("no-op re-set changed something: %+v", res) } // Replacing the set drops the old prefixes and adds the new one. res = commit(rm, func(m *Mutation) { m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ 1: {pfx("fe80::5678/128")}, }) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128", "fe80::5678/128"}, }) wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) wantOutbound(t, rm, "fe80::5678", k1, true) // Clearing the set removes the remaining extra. res = commit(rm, func(m *Mutation) { m.SetExtraAllowedIPs(nil) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128"}, }) wantOutbound(t, rm, "fe80::5678", key.NodePublic{}, false) } func TestExtraAllowedIPsPeerLifecycle(t *testing.T) { rm := New(t.Logf) // Extras for a peer that doesn't exist yet have no visible // effect until the peer is upserted. res := commit(rm, func(m *Mutation) { m.SetExtraAllowedIPs(map[tailcfg.NodeID][]netip.Prefix{ 1: {pfx("fe80::1234/128")}, }) }) if !res.ExtrasChanged || res.OutboundChanged || res.AllowedIPs != nil { t.Errorf("extras for unknown peer: %+v", res) } wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) res = commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{ k1: {"100.64.0.1/32", "fd7a:115c:a1e0::1/128", "fe80::1234/128"}, }) wantOutbound(t, rm, "fe80::1234", k1, true) // Removing the peer removes its extras from the tables, but the // stored entry survives and re-applies if the peer comes back. res = commit(rm, func(m *Mutation) { m.RemovePeer(1) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{k1: nil}) wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) wantOutbound(t, rm, "fe80::1234", k1, true) // A non-communicable peer (no addresses or routes) contributes // no prefixes at all, including its extras. res = commit(rm, func(m *Mutation) { m.upsertPeer(peerView{ID: 1, Key: k1}) }) wantChangedAllowedIPs(t, res, map[key.NodePublic][]string{k1: nil}) wantOutbound(t, rm, "fe80::1234", key.NodePublic{}, false) // Extras survive a prefs-driven full rebuild. commit(rm, func(m *Mutation) { m.upsertPeer(peer1()) }) commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{RouteAll: true}) }) wantOutbound(t, rm, "fe80::1234", k1, true) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") } // Tests that the exit routes stay in the OS route set whenever an // exit node is selected, even one that resolves to no current peer // (Prefs.ExitNodeSelected with a zero ExitNodeID), so internet // traffic is blackholed rather than escaping to the local network. // This mirrors the long-standing behavior documented on // ipn.Prefs.ExitNodeID and relied on by MDM's "auto:any" placeholder. func TestExitNodeBlackhole(t *testing.T) { rm := New(t.Logf) exitPeer := peer1() exitPeer.Routes = tsaddr.ExitRoutes() commit(rm, func(m *Mutation) { m.upsertPeer(exitPeer) }) // No exit node selected: no default routes, no outbound winner. wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48") wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) // A resolved exit node carries the default routes. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{ExitNodeID: 1, ExitNodeSelected: true}) }) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0") wantOutbound(t, rm, "8.8.8.8", k1, true) // An unresolved exit node (nonexistent, or not yet resolved) // keeps the default routes with no outbound winner: a blackhole. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{ExitNodeID: 0, ExitNodeSelected: true}) }) wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0") wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) // Removing the exit-capable peer while still selected keeps the // blackhole in place via the incremental path. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{ExitNodeID: 1, ExitNodeSelected: true}) }) commit(rm, func(m *Mutation) { m.RemovePeer(1) }) wantOSRoutes(t, rm, "0.0.0.0/0", "::/0") wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false) // Deselecting the exit node removes the default routes. commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) }) wantOSRoutes(t, rm) }