Files
tailscale/net/routecheck/routecheck_test.go
T
Simon LawandGitHub 2ee9eacb94 client/local,ipn/localapi: add /localapi/v0/routecheck endpoint (#19640)
In order to support a `tailscale routecheck` command, we introduce the
`/localapi/v0/routecheck` endpoint to the local API. This endpoint
returns the most recent report collected by the routecheck client.
If `force=true` is an argument in the query string, then this endpoint
will actively probe before returning the report.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-01 11:06:14 -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.NetMapWithPeers()) }
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)
}
}