client/local,ipn/localapi: add /localapi/v0/routecheck endpoint

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-04-01 12:53:34 -07:00
parent 674d438282
commit 0af7dc12ba
No known key found for this signature in database
GPG Key ID: B83D1EE07548341D
14 changed files with 232 additions and 27 deletions

View File

@ -0,0 +1,26 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_routecheck
package local
import (
"context"
"fmt"
"net/url"
"strconv"
"tailscale.com/ipn/routecheck"
)
// RouteCheck performs a routecheck probe to the provided IPs and waits for its report.
func (lc *Client) RouteCheck(ctx context.Context, force bool) (*routecheck.Report, error) {
v := url.Values{}
v.Set("force", strconv.FormatBool(force))
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck?"+v.Encode(), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*routecheck.Report](body)
}

View File

@ -94,6 +94,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/ipn from tailscale.com/client/local
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/routecheck from tailscale.com/client/local
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/metrics from tailscale.com/cmd/derper+
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial
@ -134,7 +135,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/netmap from tailscale.com/ipn+
tailscale.com/types/opt from tailscale.com/envknob+
tailscale.com/types/persist from tailscale.com/ipn+
tailscale.com/types/preftype from tailscale.com/ipn

View File

@ -811,6 +811,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/routecheck from tailscale.com/ipn/localapi
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
@ -825,6 +826,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/localapi from tailscale.com/tsnet
tailscale.com/ipn/routecheck from tailscale.com/feature/routecheck+
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/store/kubestore from tailscale.com/cmd/k8s-operator
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+

View File

@ -201,6 +201,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscale/cli
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/routecheck from tailscale.com/client/local
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/licenses from tailscale.com/client/web+
tailscale.com/metrics from tailscale.com/tsweb+

View File

@ -303,7 +303,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/posture from tailscale.com/feature/condregister
tailscale.com/feature/relayserver from tailscale.com/feature/condregister
tailscale.com/feature/routecheck from tailscale.com/feature/condregister
tailscale.com/feature/routecheck from tailscale.com/feature/condregister+
L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
@ -328,7 +328,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver+
tailscale.com/ipn/policy from tailscale.com/feature/portlist
tailscale.com/ipn/routecheck from tailscale.com/feature/routecheck
tailscale.com/ipn/routecheck from tailscale.com/feature/routecheck+
tailscale.com/ipn/store from tailscale.com/cmd/tailscaled+
L tailscale.com/ipn/store/awsstore from tailscale.com/feature/condregister
L tailscale.com/ipn/store/kubestore from tailscale.com/feature/condregister

View File

@ -230,6 +230,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/routecheck from tailscale.com/ipn/localapi
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
@ -244,6 +245,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/localapi from tailscale.com/tsnet
tailscale.com/ipn/routecheck from tailscale.com/feature/routecheck+
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
tailscale.com/kube/kubetypes from tailscale.com/envknob

View File

@ -7,9 +7,30 @@ package routecheck
import (
"tailscale.com/ipn/ipnext"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/routecheck"
)
// DefaultTimeout is the default time allowed for a response before a peer is considered unreachable.
const DefaultTimeout = routecheck.DefaultTimeout
// Client generates Reports describing the result of both passive and active
// reachability probing.
type Client = routecheck.Client
// ClientFor returns the [routecheck.Client] for a given backend,
// or nil if route checking is not available for that backend.
func ClientFor(b *ipnlocal.LocalBackend) *Client {
e, ok := ipnlocal.GetExt[*Extension](b)
if e == nil || !ok {
return nil
}
return e.Client
}
// Report contains the result of a single routecheck.
type Report = routecheck.Report
// NodeBackender is a shim between [ipnext.Host] and [routecheck.NodeBackender].
type nodeBackender struct{ ipnext.Host }

View File

@ -125,6 +125,9 @@ func init() {
if buildfeatures.HasUserMetrics {
Register("usermetrics", (*Handler).serveUserMetrics)
}
if buildfeatures.HasRouteCheck {
Register("routecheck", (*Handler).serveRouteCheck)
}
if buildfeatures.HasServe {
Register("query-feature", (*Handler).serveQueryFeature)
}
@ -1152,6 +1155,17 @@ func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(res)
}
func defDuration(a string, def time.Duration) time.Duration {
if a == "" {
return def
}
v, err := time.ParseDuration(a)
if err != nil {
return def
}
return v
}
func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.POST {
http.Error(w, "POST required", http.StatusMethodNotAllowed)

View File

@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_routecheck
package localapi
import (
"net/http"
"tailscale.com/feature"
)
func (h *Handler) serveRouteCheck(w http.ResponseWriter, r *http.Request) {
panic(feature.ErrUnavailable.Error())
}

View File

@ -0,0 +1,58 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_routecheck
package localapi
import (
"encoding/json"
"net/http"
"time"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/feature/routecheck"
"tailscale.com/util/httpm"
)
func (h *Handler) serveRouteCheck(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasRouteCheck {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
rc := routecheck.ClientFor(h.b)
if rc == nil {
http.Error(w, "routecheck is not enabled", http.StatusServiceUnavailable)
return
}
if r.Method != httpm.POST {
http.Error(w, "want POST", http.StatusBadRequest)
return
}
var err error
var report *routecheck.Report
if defBool(r.FormValue("force"), false) {
timeout := defDuration(r.FormValue("timeout"), routecheck.DefaultTimeout)
timeout = min(max(0, timeout), 60*time.Second) // clamp to [0s, 60s]
report, err = rc.Refresh(r.Context(), timeout)
} else {
report = rc.Report()
}
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Header().Set("Content-Type", "text.plain")
w.Write([]byte(err.Error()))
return
}
w.Header().Set("Content-Type", "application/json")
if report == nil {
w.WriteHeader(http.StatusNoContent)
return
}
json.NewEncoder(w).Encode(report)
}

View File

@ -5,7 +5,10 @@ package routecheck
import (
"context"
"encoding/json"
"maps"
"net/netip"
"slices"
"time"
"tailscale.com/tailcfg"
@ -20,9 +23,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
}
@ -30,26 +33,51 @@ 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"`
}
// 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 encodes itself as an array.
type nodeset map[tailcfg.NodeID]Node
var _ json.Marshaler = nodeset{}
var _ json.Unmarshaler = nodeset{}
// MarshalJSON implements the [json.Marshaler] interface.
func (ns nodeset) MarshalJSON() ([]byte, error) {
nodes := maps.Values(ns)
return json.Marshal(slices.Collect(nodes))
}
// MarshalJSON implements the [json.Unmarshaler] interface.
func (ns nodeset) UnmarshalJSON(b []byte) error {
var nodes []Node
if err := json.Unmarshal(b, &nodes); err != nil {
return err
}
for _, n := range nodes {
ns[n.ID] = n
}
return nil
}

View File

@ -7,7 +7,9 @@ package routecheck
import (
"context"
"errors"
"fmt"
"net/netip"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
@ -81,3 +83,13 @@ func NewClient(logf logger.Logf, nb NodeBackender, nm NetMapWaiter, pinger Pinge
pinger: pinger,
}, nil
}
// 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) {
r, err := c.ProbeAllHARouters(ctx, 5, timeout)
if err != nil {
return nil, fmt.Errorf("error probing routers: %w", err)
}
return r, nil
}

View File

@ -26,7 +26,7 @@ import (
"tailscale.com/util/set"
)
func TestReport(t *testing.T) {
func TestRefresh(t *testing.T) {
for _, tc := range []struct {
name string
init bool // true before the netmap has been loaded
@ -35,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",
@ -128,29 +132,32 @@ func TestReport(t *testing.T) {
t.Run(tc.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(tc.gone...))
self := makeNode(99, withName("self"))
var b *stubBackend
if !tc.init {
self := makeNode(99, withName("self"))
b = newStubBackend(self, tc.peers, withGone(tc.gone...))
} else {
// The backend is initialized without a NetMap,
// which gets “retrieved” after a delay.
b = newStubBackend(self, tc.peers, withGone(tc.gone...), withDelay(10*time.Second))
}
c, err := routecheck.NewClient(t.Logf, b, b, b)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := c.Report()
got, err := c.Refresh(t.Context(), routecheck.DefaultTimeout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
now := time.Now() // synctest will freeze time.
var want *routecheck.Report
peers := makeDB(tc.peers)
if !tc.init {
want = &routecheck.Report{
Done: now,
}
for _, nid := range tc.want {
mak.Set(&want.Reachable, nid, peers[nid])
}
want := &routecheck.Report{
Done: now,
}
for _, nid := range tc.want {
mak.Set(&want.Reachable, nid, peers[nid])
}
if diff := cmpDiff(want, got); diff != "" {
@ -414,14 +421,21 @@ type stubBackend struct {
self tailcfg.NodeView
peers []tailcfg.NodeView
gone set.Set[tailcfg.NodeID]
delay context.Context
}
type backendOptFunc func(*stubBackend)
func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...backendOptFunc) *stubBackend {
if !self.Valid() {
panic("invalid self")
}
delay, _ := context.WithTimeout(context.Background(), 0) // No delay
b := &stubBackend{
self: self,
peers: slices.Clone(peers),
delay: delay,
}
for _, opt := range opts {
opt(b)
@ -430,7 +444,8 @@ func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...bac
}
func (b *stubBackend) NetMap() *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{
@ -440,6 +455,7 @@ func (b *stubBackend) NetMap() *netmap.NetworkMap {
}
func (b *stubBackend) WaitForNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
<-b.delay.Done()
nm := b.NetMap()
if nm == nil {
<-ctx.Done()
@ -485,6 +501,12 @@ func (b *stubBackend) Ping(ip netip.Addr, pingType tailcfg.PingType, size int, c
}
}
func withDelay(d time.Duration) backendOptFunc {
return func(b *stubBackend) {
b.delay, _ = context.WithTimeout(context.Background(), d)
}
}
func withGone(gone ...tailcfg.NodeID) backendOptFunc {
return func(b *stubBackend) {
b.gone = set.SetOf(gone)

View File

@ -226,6 +226,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/routecheck from tailscale.com/ipn/localapi
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
@ -240,6 +241,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/localapi from tailscale.com/tsnet
tailscale.com/ipn/routecheck from tailscale.com/feature/routecheck+
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
tailscale.com/kube/kubetypes from tailscale.com/envknob