Files
tailscale/net/routecheck/routecheck_test.go
T
Simon LawandBrad Fitzpatrick d8ee47d1cf ipn/ipnext, feature/routecheck: subscribe routecheck to the IPN bus
This patch adds a new ipnext.NotifyWatcher interface that exposes
ipn.LocalBackend.WatchNotifications so that extensions inside
tailscaled can subscribe to the IPN bus, much like how the GUI
clients subscribe to it through the Local API.

This interface is used by the new feature/routecheck.RouterTracker to
watch for changes in the peer map that affect routers. RouterTracker
uses dead reckoning to incrementally maintain the set of routers. We
do this to avoid looping over the peer map repeatedly. See #17366.

RouterTracker supports two hooks:

- OnNetMapAvailable signals that the initial netmap has been received,
  so that the routecheck.Client can wake up goroutines that are
  waiting for it.

- OnRoutersChange signals that the set of routers has changed, so that
  the routecheck.Client can decide to probe a subset of the routers
  instead of all of them. Currently, this optimization hasn’t been
  implemented yet.

Updates #17366
Updates #20062
Updates tailscale/corp#33033

Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00

563 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routecheck_test
import (
"context"
"fmt"
"maps"
"net/netip"
"slices"
"sync/atomic"
"testing"
"testing/synctest"
"time"
gcmp "github.com/google/go-cmp/cmp"
gcmpopts "github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/routecheck"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
func TestRefresh(t *testing.T) {
for _, tt := range []struct {
name string
init bool // true before the netmap has been loaded
peers []tailcfg.NodeView
gone []tailcfg.NodeID // cannot ping these nodes
want []tailcfg.NodeID // Report.Reachable nodes
}{
{
name: "wait-for-netmap",
init: true,
peers: []tailcfg.NodeView{
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
},
want: []tailcfg.NodeID{11, 12},
},
{
name: "no-peers",
peers: []tailcfg.NodeView{},
want: []tailcfg.NodeID{},
},
{
name: "no-routers",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
},
want: []tailcfg.NodeID{},
},
{
name: "no-choice",
peers: []tailcfg.NodeView{
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
},
want: []tailcfg.NodeID{},
},
{
name: "all-good",
peers: []tailcfg.NodeView{
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
makeNode(22, withName("subnet22"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
},
want: []tailcfg.NodeID{11, 12, 21, 22},
},
{
name: "none-good",
peers: []tailcfg.NodeView{
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
makeNode(22, withName("subnet22"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
},
gone: []tailcfg.NodeID{11, 12, 21, 22},
want: []tailcfg.NodeID{},
},
{
name: "some-good",
peers: []tailcfg.NodeView{
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
makeNode(22, withName("subnet22"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
},
gone: []tailcfg.NodeID{11, 22},
want: []tailcfg.NodeID{12, 21},
},
} {
makeDB := func(nodes []tailcfg.NodeView) map[tailcfg.NodeID]routecheck.Node {
if len(nodes) == 0 {
return nil
}
db := make(map[tailcfg.NodeID]routecheck.Node)
for _, n := range nodes {
db[n.ID()] = routecheck.Node{
ID: n.ID(),
Name: n.Name(),
Addr: n.Addresses().At(0).Addr(),
Routes: n.AllowedIPs().AsSlice()[2:],
}
}
return db
}
cmpDiff := func(want, got any) string {
return gcmp.Diff(want, got,
gcmpopts.EquateComparable(netip.Addr{}, netip.Prefix{}))
}
t.Run(tt.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
self := makeNode(99, withName("self"))
var b *stubBackend
if !tt.init {
b = newStubBackend(self, tt.peers,
withGone(t, tt.gone...))
} else {
// The backend is initialized without a NetMap,
// which gets “retrieved” after a delay.
b = newStubBackend(self, tt.peers,
withGone(t, tt.gone...),
withDelay(t, 10*time.Second))
}
t.Cleanup(func() { b.Close() })
c, err := routecheck.NewClient(t.Logf, b, b, b)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.init {
// This callback simulates the delay between
// connecting to the backend and receiving the NetMap.
donef := func() { c.NotifyNetMapAvailable() }
b.donef.Store(&donef)
}
before := time.Now()
got, err := c.Refresh(t.Context(), routecheck.DefaultTimeout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now() // synctest will freeze time.
peers := makeDB(tt.peers)
want := &routecheck.Report{
Done: after,
}
for _, nid := range tt.want {
mak.Set(&want.Reachable, nid, peers[nid])
}
for _, nodes := range c.RoutersByPrefix() {
if len(nodes) <= 1 {
continue // no choice
}
for _, n := range nodes {
ts := before
if tt.init {
ts = after // waiting for netmap
}
if slices.Contains(tt.gone, n.ID()) {
ts = after // ping timed out
}
mak.Set(&want.LastProbed, n.ID(), ts)
}
}
if diff := cmpDiff(want, got); diff != "" {
t.Errorf("-want +got:\n%s", diff)
}
})
})
}
}
func TestRoutersByPrefix(t *testing.T) {
type routersByPrefix map[netip.Prefix][]tailcfg.NodeID
simplify := func(rs routecheck.RoutersByPrefix) routersByPrefix {
out := make(routersByPrefix, len(rs))
for p, ns := range rs {
for _, n := range ns {
out[p] = append(out[p], n.ID())
}
slices.Sort(out[p])
}
return out
}
for _, tt := range []struct {
name string
peers []tailcfg.NodeView
want routersByPrefix
}{
{
name: "no-peers",
peers: []tailcfg.NodeView{},
want: routersByPrefix{},
},
{
name: "no-routers",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
},
want: routersByPrefix{},
},
{
name: "one-exit-node",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(11, withName("exit11"), withExitRoutes()),
},
want: routersByPrefix{
netip.MustParsePrefix("0.0.0.0/0"): {11},
netip.MustParsePrefix("::/0"): {11},
},
},
{
name: "overlapping-exit-nodes",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
},
want: routersByPrefix{
netip.MustParsePrefix("0.0.0.0/0"): {11, 12},
netip.MustParsePrefix("::/0"): {11, 12},
},
},
{
name: "one-subnet-router",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
},
want: routersByPrefix{
netip.MustParsePrefix("192.168.1.0/24"): {21},
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
},
},
{
name: "overlapping-subnet-routers",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
makeNode(22, withName("subnet22"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
},
want: routersByPrefix{
netip.MustParsePrefix("192.168.1.0/24"): {21, 22},
netip.MustParsePrefix("2002:c000:0100::/48"): {21, 22},
},
},
{
name: "disjoint-subnet-routers",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
makeNode(22, withName("subnet22"),
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
},
want: routersByPrefix{
netip.MustParsePrefix("192.168.1.0/24"): {21},
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
netip.MustParsePrefix("192.168.2.0/24"): {22},
netip.MustParsePrefix("2002:c000:0200::/48"): {22},
},
},
{
name: "multiple-routes",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48")),
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
makeNode(22, withName("subnet22"),
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48")),
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48"))),
makeNode(23, withName("subnet23"),
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48")),
withRoutes(netip.MustParsePrefix("192.168.4.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0400::/48"))),
},
want: routersByPrefix{
netip.MustParsePrefix("192.168.1.0/24"): {21},
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
netip.MustParsePrefix("192.168.2.0/24"): {21, 22},
netip.MustParsePrefix("2002:c000:0200::/48"): {21, 22},
netip.MustParsePrefix("192.168.3.0/24"): {22, 23},
netip.MustParsePrefix("2002:c000:0300::/48"): {22, 23},
netip.MustParsePrefix("192.168.4.0/24"): {23},
netip.MustParsePrefix("2002:c000:0400::/48"): {23},
},
},
{
name: "both-exit-nodes-and-routers",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
makeNode(21, withName("subnet21"),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48")),
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
makeNode(22, withName("subnet22"),
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48")),
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48"))),
},
want: routersByPrefix{
netip.MustParsePrefix("0.0.0.0/0"): {11, 12},
netip.MustParsePrefix("::/0"): {11, 12},
netip.MustParsePrefix("192.168.1.0/24"): {21},
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
netip.MustParsePrefix("192.168.2.0/24"): {21, 22},
netip.MustParsePrefix("2002:c000:0200::/48"): {21, 22},
netip.MustParsePrefix("192.168.3.0/24"): {22},
netip.MustParsePrefix("2002:c000:0300::/48"): {22},
},
},
{
name: "mixed-nodes",
peers: []tailcfg.NodeView{
makeNode(1, withName("peer1")),
makeNode(31, withName("router31"),
withExitRoutes(),
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48")),
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
makeNode(32, withName("router32"),
withExitRoutes(),
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48")),
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48"))),
},
want: routersByPrefix{
netip.MustParsePrefix("0.0.0.0/0"): {31, 32},
netip.MustParsePrefix("::/0"): {31, 32},
netip.MustParsePrefix("192.168.1.0/24"): {31},
netip.MustParsePrefix("2002:c000:0100::/48"): {31},
netip.MustParsePrefix("192.168.2.0/24"): {31, 32},
netip.MustParsePrefix("2002:c000:0200::/48"): {31, 32},
netip.MustParsePrefix("192.168.3.0/24"): {32},
netip.MustParsePrefix("2002:c000:0300::/48"): {32},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
self := makeNode(99, withName("self"))
b := newStubBackend(self, tt.peers)
t.Cleanup(func() { b.Close() })
c, err := routecheck.NewClient(t.Logf, b, b, b)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := simplify(c.RoutersByPrefix())
if !maps.EqualFunc(got, tt.want, slices.Equal) {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
type nodeOptFunc func(*tailcfg.Node)
func makeNode(id tailcfg.NodeID, opts ...nodeOptFunc) tailcfg.NodeView {
addresses := []netip.Prefix{
netip.MustParsePrefix(fmt.Sprintf("192.168.0.%d/32", id)),
netip.MustParsePrefix(fmt.Sprintf("fd7a:115c:a1e0::%d/128", id)),
}
node := &tailcfg.Node{
ID: id,
StableID: tailcfg.StableNodeID(fmt.Sprintf("stable%d", id)),
Name: fmt.Sprintf("node%d", id),
Online: new(true),
MachineAuthorized: true,
HomeDERP: int(id),
Addresses: addresses,
AllowedIPs: addresses,
}
for _, opt := range opts {
opt(node)
}
return node.View()
}
func withExitRoutes() nodeOptFunc {
return withRoutes(tsaddr.ExitRoutes()...)
}
func withName(name string) nodeOptFunc {
return func(n *tailcfg.Node) {
n.Name = name
}
}
func withRoutes(routes ...netip.Prefix) nodeOptFunc {
return func(n *tailcfg.Node) {
n.AllowedIPs = append(n.AllowedIPs, routes...)
}
}
var _ routecheck.NodeBackender = &stubBackend{}
var _ routecheck.NodeBackend = &stubBackend{}
var _ routecheck.NetMapper = &stubBackend{}
var _ routecheck.Pinger = &stubBackend{}
type stubBackend struct {
self tailcfg.NodeView
peers []tailcfg.NodeView
gone set.Set[tailcfg.NodeID]
delay context.Context
cancel context.CancelFunc
donef atomic.Pointer[func()]
}
type backendOptFunc func(*stubBackend)
func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...backendOptFunc) *stubBackend {
if !self.Valid() {
panic("invalid self")
}
delay, cancel := context.WithTimeout(context.Background(), 0) // No delay
b := &stubBackend{
self: self,
peers: slices.Clone(peers),
delay: delay,
cancel: cancel,
}
for _, opt := range opts {
opt(b)
}
return b
}
func (b *stubBackend) Close() error {
if b.cancel != nil {
b.cancel()
}
return nil
}
func (b *stubBackend) NetMapNoPeers() *netmap.NetworkMap {
if b.delay.Err() == nil {
// Simulate the delay between startup and receiving the NetMap.
return nil
}
return &netmap.NetworkMap{
SelfNode: b.self,
Peers: nil, // No peers.
}
}
func (b *stubBackend) NetMapWithPeers() *netmap.NetworkMap {
nm := b.NetMapNoPeers()
if nm != nil {
nm.Peers = b.peers
}
return nm
}
func (nb *stubBackend) NodeBackend() routecheck.NodeBackend {
return nb
}
func (nb *stubBackend) Self() tailcfg.NodeView {
return nb.self
}
func (nb *stubBackend) Peers() []tailcfg.NodeView {
return nb.peers
}
func (b *stubBackend) Ping(ip netip.Addr, pingType tailcfg.PingType, size int, cb func(*ipnstate.PingResult)) {
// Does the IP address match one of the peers addresses?
for _, n := range b.peers {
for _, a := range n.Addresses().All() {
if a.Addr() != ip {
continue
}
if b.gone.Contains(n.ID()) {
continue
}
go cb(&ipnstate.PingResult{
IP: ip.String(),
NodeIP: ip.String(),
NodeName: n.Name(),
LatencySeconds: 0.01,
})
}
}
}
func withDelay(t *testing.T, d time.Duration) backendOptFunc {
return func(b *stubBackend) {
t.Helper()
var stopf func() bool
ctx, cancel := context.WithTimeout(t.Context(), d)
stopf = context.AfterFunc(ctx, func() {
if donef := b.donef.Load(); donef != nil {
(*donef)()
}
cancel()
stopf()
})
b.delay = ctx
}
}
func withGone(t *testing.T, gone ...tailcfg.NodeID) backendOptFunc {
return func(b *stubBackend) {
t.Helper()
b.gone = set.SetOf(gone)
}
}