From 0af7dc12ba63959186a41e9aa26d2dbfdfec0de7 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 1 Apr 2026 12:53:34 -0700 Subject: [PATCH] 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 --- client/local/routecheck.go | 26 +++++++++++++ cmd/derper/depaware.txt | 3 +- cmd/k8s-operator/depaware.txt | 2 + cmd/tailscale/depaware.txt | 1 + cmd/tailscaled/depaware.txt | 4 +- cmd/tsidp/depaware.txt | 2 + feature/routecheck/ipn.go | 21 +++++++++++ ipn/localapi/localapi.go | 14 +++++++ ipn/localapi/routecheck_disabled.go | 16 ++++++++ ipn/localapi/routecheck_enabled.go | 58 +++++++++++++++++++++++++++++ ipn/routecheck/report.go | 44 ++++++++++++++++++---- ipn/routecheck/routecheck.go | 12 ++++++ ipn/routecheck/routecheck_test.go | 54 +++++++++++++++++++-------- tsnet/depaware.txt | 2 + 14 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 client/local/routecheck.go create mode 100644 ipn/localapi/routecheck_disabled.go create mode 100644 ipn/localapi/routecheck_enabled.go diff --git a/client/local/routecheck.go b/client/local/routecheck.go new file mode 100644 index 000000000..6afacb147 --- /dev/null +++ b/client/local/routecheck.go @@ -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) +} diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index ec59c7264..7ebb3e2d9 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -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 diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index a3340d03b..40f5870d5 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -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+ diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 01d3f418f..b0ec2fc00 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -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+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 7dd6155fb..33aa2b6d6 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -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 diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 360437860..9357e445d 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -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 diff --git a/feature/routecheck/ipn.go b/feature/routecheck/ipn.go index 6daf06fd7..6bca95142 100644 --- a/feature/routecheck/ipn.go +++ b/feature/routecheck/ipn.go @@ -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 } diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 43942c52f..9ad3276a2 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -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) diff --git a/ipn/localapi/routecheck_disabled.go b/ipn/localapi/routecheck_disabled.go new file mode 100644 index 000000000..53c02198d --- /dev/null +++ b/ipn/localapi/routecheck_disabled.go @@ -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()) +} diff --git a/ipn/localapi/routecheck_enabled.go b/ipn/localapi/routecheck_enabled.go new file mode 100644 index 000000000..fed0a2a4f --- /dev/null +++ b/ipn/localapi/routecheck_enabled.go @@ -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) +} diff --git a/ipn/routecheck/report.go b/ipn/routecheck/report.go index a3a48d70b..758d2be74 100644 --- a/ipn/routecheck/report.go +++ b/ipn/routecheck/report.go @@ -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 } diff --git a/ipn/routecheck/routecheck.go b/ipn/routecheck/routecheck.go index a0924efef..b3c2f1831 100644 --- a/ipn/routecheck/routecheck.go +++ b/ipn/routecheck/routecheck.go @@ -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 doesn’t 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 +} diff --git a/ipn/routecheck/routecheck_test.go b/ipn/routecheck/routecheck_test.go index 39ae7f4c3..596f28d68 100644 --- a/ipn/routecheck/routecheck_test.go +++ b/ipn/routecheck/routecheck_test.go @@ -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) diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index b8b6aec98..d71152865 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -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