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>
This commit is contained in:
Simon Law
2026-06-01 11:06:14 -07:00
committed by GitHub
parent 28801674a6
commit 2ee9eacb94
14 changed files with 335 additions and 35 deletions
+16
View File
@@ -50,6 +50,12 @@ func (c *Client) probe(ctx context.Context, nodes iter.Seq[probed], limit int, t
var mu syncs.Mutex
r := &Report{}
timestampProbe := func(n probed) {
mu.Lock()
defer mu.Unlock()
mak.Set(&r.LastProbed, n.ID(), time.Now())
}
markReachable := func(n probed) {
mu.Lock()
defer mu.Unlock()
@@ -81,12 +87,22 @@ func (c *Client) probe(ctx context.Context, nodes iter.Seq[probed], limit int, t
// TODO(sfllaw): Add a mechanism to mark a node as unreachable
// because it fails of establish a new WireGuard connection.
if n.IsWireGuardOnly() {
timestampProbe(n)
markReachable(n)
continue
}
g.Go(func() error {
metricPing.Add(1)
// We record the timestamp of each nodes latest probe
// so we can probe in incremental batches
// and to limit the rate that any given node is pinged.
//
// TODO(sfllaw): We currently record the timestamp
// but havent implemented batching or rate-limiting yet.
defer timestampProbe(n)
// TODO(sfllaw): Why did we choose Disco ping instead of TSMP ping?
// After all, a TSMP ping proves that the peer Tailscale node is there
// and that both nodes know each others WireGuard keys,
+49 -8
View File
@@ -4,10 +4,16 @@
package routecheck
import (
"cmp"
"context"
"maps"
"net/netip"
"slices"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
)
@@ -26,9 +32,9 @@ func (c *Client) Report() *Report {
}
// TODO(sfllaw): Return the latest snapshot produced by background probing.
r, err := c.ProbeAllHARouters(context.TODO(), 5, DefaultTimeout)
r, err := c.Refresh(context.TODO(), DefaultTimeout)
if err != nil {
c.logf("reachability report error: %v", err)
c.logf("%v", err)
}
return r
}
@@ -36,26 +42,61 @@ func (c *Client) Report() *Report {
// Report contains the result of a single routecheck.
type Report struct {
// Done is the time when the report was finished.
Done time.Time
Done time.Time `json:"done"`
// Reachable is the set of nodes that were reachable from the current host
// when this report was compiled. Missing nodes may or may not be reachable.
Reachable map[tailcfg.NodeID]Node
Reachable NodeSet `json:"reachable"`
// LastProbed tracks the last time a given node was probed.
// This is used to rate-limit reachability probing, so an entrys
// presence doesnt imply that it is reachable.
LastProbed map[tailcfg.NodeID]time.Time `json:"-"` // not marshaled
}
// Node represents a node in the reachability report.
type Node struct {
ID tailcfg.NodeID
ID tailcfg.NodeID `json:"id"`
// Name is the FQDN of the node.
// It is also the MagicDNS name for the node.
// It has a trailing dot.
// e.g. "host.tail-scale.ts.net."
Name string
Name string `json:"name"`
// Addr is the IP address that was probed.
Addr netip.Addr
Addr netip.Addr `json:"addr"`
// Routes are the subnets that the node will route.
Routes []netip.Prefix
Routes []netip.Prefix `json:"routes"`
}
// NodeSet is a set of nodes keyed by node ID, so duplicates are easily detected.
// To prevent stuttering, it marshals itself as a JSON array, sorted by node ID.
type NodeSet map[tailcfg.NodeID]Node
var _ jsonv2.MarshalerTo = &NodeSet{}
var _ jsonv2.UnmarshalerFrom = &NodeSet{}
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (ns NodeSet) MarshalJSONTo(enc *jsontext.Encoder) error {
nodes := slices.SortedFunc(maps.Values(ns), func(a, b Node) int {
return cmp.Compare(a.ID, b.ID)
})
return jsonv2.MarshalEncode(enc, nodes)
}
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (ns *NodeSet) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var nodes []Node
if err := jsonv2.UnmarshalDecode(dec, &nodes); err != nil {
return err
}
if *ns == nil {
*ns = make(NodeSet, len(nodes))
}
for _, n := range nodes {
(*ns)[n.ID] = n
}
return nil
}
+18
View File
@@ -7,13 +7,20 @@ package routecheck
import (
"context"
"errors"
"fmt"
"net/netip"
"sync/atomic"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/util/clientmetric"
)
var (
metricRefresh = clientmetric.NewCounter("routecheck_refresh")
)
// Client generates Reports describing the result of both passive and active
@@ -150,6 +157,17 @@ func (c *Client) waitForNetMap(ctx context.Context) (*netmap.NetworkMap, error)
}
}
// Refresh generates a new reachability report and returns it.
// A peer is considered unreachable if it doesnt respond within the timeout.
func (c *Client) Refresh(ctx context.Context, timeout time.Duration) (*Report, error) {
metricRefresh.Add(1)
r, err := c.ProbeAllHARouters(ctx, 5, timeout)
if err != nil {
return nil, fmt.Errorf("error probing routers: %w", err)
}
return r, nil
}
// Close immediately stops all active probes.
func (c *Client) Close() error {
if c == nil {
+94 -19
View File
@@ -4,10 +4,12 @@
package routecheck_test
import (
"context"
"fmt"
"maps"
"net/netip"
"slices"
"sync/atomic"
"testing"
"testing/synctest"
"time"
@@ -24,7 +26,7 @@ import (
"tailscale.com/util/set"
)
func TestReport(t *testing.T) {
func TestRefresh(t *testing.T) {
for _, tt := range []struct {
name string
init bool // true before the netmap has been loaded
@@ -33,9 +35,13 @@ func TestReport(t *testing.T) {
want []tailcfg.NodeID // Report.Reachable nodes
}{
{
name: "before-netmap",
name: "wait-for-netmap",
init: true,
want: nil,
peers: []tailcfg.NodeView{
makeNode(11, withName("exit11"), withExitRoutes()),
makeNode(12, withName("exit12"), withExitRoutes()),
},
want: []tailcfg.NodeID{11, 12},
},
{
name: "no-peers",
@@ -126,28 +132,59 @@ func TestReport(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// The backend is initialized without a NetMap.
b := newStubBackend(tailcfg.NodeView{}, nil, withGone(tt.gone...))
self := makeNode(99, withName("self"))
var b *stubBackend
if !tt.init {
self := makeNode(99, withName("self"))
b = newStubBackend(self, tt.peers, withGone(tt.gone...))
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)
}
got := c.Report()
now := time.Now() // synctest will freeze time.
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.
var want *routecheck.Report
peers := makeDB(tt.peers)
if !tt.init {
want = &routecheck.Report{
Done: now,
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 _, nid := range tt.want {
mak.Set(&want.Reachable, nid, peers[nid])
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)
}
}
@@ -350,6 +387,7 @@ func TestRoutersByPrefix(t *testing.T) {
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)
@@ -412,14 +450,26 @@ 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),
self: self,
peers: slices.Clone(peers),
delay: delay,
cancel: cancel,
}
for _, opt := range opts {
opt(b)
@@ -427,8 +477,16 @@ func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...bac
return b
}
func (b *stubBackend) Close() error {
if b.cancel != nil {
b.cancel()
}
return nil
}
func (b *stubBackend) NetMapNoPeers() *netmap.NetworkMap {
if !b.self.Valid() {
if b.delay.Err() == nil {
// Simulate the delay between startup and receiving the NetMap.
return nil
}
return &netmap.NetworkMap{
@@ -479,8 +537,25 @@ func (b *stubBackend) Ping(ip netip.Addr, pingType tailcfg.PingType, size int, c
}
}
func withGone(gone ...tailcfg.NodeID) backendOptFunc {
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)
}
+1 -2
View File
@@ -39,11 +39,10 @@ func (c *Client) RoutersByPrefix() RoutersByPrefix {
// The result omits any prefix that is one of the nodes local addresses.
func routes(n tailcfg.NodeView) []netip.Prefix {
var routes []netip.Prefix
AllowedIPs:
for _, pfx := range n.AllowedIPs().All() {
// Routers never forward their own local addresses.
if views.SliceContains(n.Addresses(), pfx) {
continue AllowedIPs
continue
}
routes = append(routes, pfx)
}