From 2d6844c565bcbc37be491511adb384ee1e3b6481 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 1 Jun 2026 11:50:24 -0700 Subject: [PATCH] cmd/tailscale/cli: add routecheck command (#19641) Introduce a new `tailscale routecheck` command which prints a report of high-availability routers that are reachable. This command rhymes with the `tailscale netcheck` command and but instead of reporting on local network conditions, `routecheck` reports on remote connectivity. Updates #17366 Updates tailscale/corp#33033 Signed-off-by: Simon Law --- cmd/derper/depaware.txt | 1 + cmd/k8s-operator/depaware.txt | 1 + cmd/tailscale/cli/cli.go | 2 + cmd/tailscale/cli/netcheck.go | 4 +- cmd/tailscale/cli/routecheck.go | 121 ++++++++++++++++++++++++++++++++ cmd/tailscale/depaware.txt | 3 +- cmd/tailscaled/depaware.txt | 2 +- cmd/tsidp/depaware.txt | 1 + net/routecheck/report.go | 71 +++++++++++++++++-- tsnet/depaware.txt | 1 + 10 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 cmd/tailscale/cli/routecheck.go diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index bac6d3546..86e2394d7 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -20,6 +20,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck 💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/hdevalence/ed25519consensus from tailscale.com/tka diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index fbe9716cd..f7a193b83 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -42,6 +42,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+ github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+ + github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck github.com/go-logr/logr from github.com/go-logr/logr/slogr+ github.com/go-logr/logr/slogr from github.com/go-logr/zapr github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+ diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 9e4b267ad..06a877874 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -239,6 +239,7 @@ type restore struct { var ( fileCmd, sysPolicyCmd, + maybeRoutecheckCmd, maybeWebCmd, maybeDriveCmd, maybeTailnetLockCmd, @@ -280,6 +281,7 @@ func newRootCmd(tb ...testenv.TB) *ffcli.Command { configureCmd(), nilOrCall(sysPolicyCmd), netcheckCmd, + nilOrCall(maybeRoutecheckCmd), ipCmd, dnsCmd, statusCmd, diff --git a/cmd/tailscale/cli/netcheck.go b/cmd/tailscale/cli/netcheck.go index 5e45445c7..c3f6f385d 100644 --- a/cmd/tailscale/cli/netcheck.go +++ b/cmd/tailscale/cli/netcheck.go @@ -143,7 +143,7 @@ func runNetcheck(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("netcheck: %w", err) } - if err := printReport(dm, report); err != nil { + if err := printNetCheckReport(dm, report); err != nil { return err } if netcheckArgs.every == 0 { @@ -153,7 +153,7 @@ func runNetcheck(ctx context.Context, args []string) error { } } -func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error { +func printNetCheckReport(dm *tailcfg.DERPMap, report *netcheck.Report) error { var j []byte var err error switch netcheckArgs.format { diff --git a/cmd/tailscale/cli/routecheck.go b/cmd/tailscale/cli/routecheck.go new file mode 100644 index 000000000..5f2ba1e1f --- /dev/null +++ b/cmd/tailscale/cli/routecheck.go @@ -0,0 +1,121 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_routecheck + +package cli + +import ( + "cmp" + "context" + "flag" + "fmt" + "slices" + "strings" + "text/tabwriter" + "time" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/net/routecheck" +) + +func init() { + maybeRoutecheckCmd = routecheckCmd +} + +var routecheckCmd = func() *ffcli.Command { + return &ffcli.Command{ + Name: "routecheck", + ShortUsage: "tailscale routecheck", + ShortHelp: "Print a reachability report for routes with multiple paths", + LongHelp: hidden + `"tailscale routecheck" is an experimental feature; it is not a stable interface`, + Exec: runRoutecheck, + FlagSet: routecheckFlagSet, + } +} + +var routecheckFlagSet = func() *flag.FlagSet { + fs := newFlagSet("routecheck") + fs.BoolVar(&routecheckArgs.probe, "probe", false, "probe now to generate a new reachability report") + fs.StringVar(&routecheckArgs.format, "format", "", `output format: empty (for human-readable), "json" or "json-line"`) + return fs +}() + +var routecheckArgs struct { + probe bool + format string +} + +func runRoutecheck(ctx context.Context, args []string) error { + routeCheck := localClient.RouteCheck + if routecheckArgs.probe { + routeCheck = localClient.RouteCheckProbe + } + rp, err := routeCheck(ctx) + if err != nil { + return fmt.Errorf("routecheck: %w", err) + } + if err := printRouteCheckReport(rp); err != nil { + return err + } + return nil +} + +func printRouteCheckReport(rp *routecheck.Report) error { + var enc *jsontext.Encoder + switch routecheckArgs.format { + case "": + case "json": + enc = jsontext.NewEncoder(Stdout, jsontext.WithIndent("\t")) + case "json-line": + enc = jsontext.NewEncoder(Stdout, jsontext.Multiline(false)) + default: + return fmt.Errorf("unknown output format %q", routecheckArgs.format) + } + + if rp == nil { + return fmt.Errorf("routecheck: report unavailable") + } + routes := rp.RoutablePrefixes() + + // Don’t render prefixes that only have one router: + for pfx, nodes := range routes { + if len(nodes) <= 1 { + delete(routes, pfx) + } + } + + if enc != nil { + out := struct { + Done time.Time `json:"done"` + Routes routecheck.RoutablePrefixes `json:"routes"` + }{ + Done: rp.Done, + Routes: routes, + } + if err := jsonv2.MarshalEncode(enc, out); err != nil { + return err + } + if _, err := Stdout.Write([]byte("\n")); err != nil { + return err + } + return nil + } + + w := tabwriter.NewWriter(Stdout, 10, 5, 5, ' ', 0) + defer w.Flush() + fmt.Fprintf(w, "\nReachable routers at %s:\n", rp.Done.Local().Format(time.DateTime+"Z07:00")) + fmt.Fprintf(w, "\n %s\t%s\t%s", "PREFIX", "IP", "HOSTNAME") + for prefix, nodes := range routes.Sorted() { + slices.SortFunc(nodes, func(a, b routecheck.Node) int { + return cmp.Compare(a.Name, b.Name) // order by hostname + }) + for _, n := range nodes { + fmt.Fprintf(w, "\n %s\t%s\t%s", prefix, n.Addr, strings.TrimSuffix(n.Name, ".")) + } + } + fmt.Fprintln(w) + return nil +} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 467c523f0..a12b12f4b 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -109,6 +109,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck L 💣 github.com/godbus/dbus/v5 from fyne.io/systray+ L github.com/godbus/dbus/v5/introspect from fyne.io/systray+ L github.com/godbus/dbus/v5/prop from fyne.io/systray @@ -220,7 +221,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/net/ping from tailscale.com/net/netcheck tailscale.com/net/portmapper from tailscale.com/feature/portmapper tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+ - tailscale.com/net/routecheck from tailscale.com/client/local + tailscale.com/net/routecheck from tailscale.com/client/local+ tailscale.com/net/sockstats from tailscale.com/control/controlhttp+ tailscale.com/net/stun from tailscale.com/net/netcheck tailscale.com/net/tlsdial from tailscale.com/cmd/tailscale/cli+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index f98b05ac2..b17ee802b 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -112,7 +112,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+ github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+ - github.com/go-json-experiment/json/v1 from tailscale.com/feature/routecheck + github.com/go-json-experiment/json/v1 from tailscale.com/feature/routecheck+ W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+ W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+ diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 23fbdb846..9552addef 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -31,6 +31,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp diff --git a/net/routecheck/report.go b/net/routecheck/report.go index 4707dc040..44a1cd217 100644 --- a/net/routecheck/report.go +++ b/net/routecheck/report.go @@ -6,6 +6,7 @@ import ( "cmp" "context" + "iter" "maps" "net/netip" "slices" @@ -13,9 +14,11 @@ jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" + jsonv1 "github.com/go-json-experiment/json/v1" "tailscale.com/tailcfg" "tailscale.com/util/clientmetric" + "tailscale.com/util/mak" ) var ( @@ -54,6 +57,28 @@ type Report struct { LastProbed map[tailcfg.NodeID]time.Time `json:"-"` // not marshaled } +// RoutablePrefixes returns a map of routable network prefixes associated with +// each prefix’s routers that were reachable by the current host, +// at the time the report was finished. +// Each slice of routers are ordered by their node ID. +// +// Note: Fallback routes are not supported by design. If a subnet prefix +// contained within another more general prefix has no reachable routers, +// traffic is still sent to one of those unreachable routers. +// Routers for the general prefix aren’t candidates. See tailscale/tailscale#18550. +func (rp Report) RoutablePrefixes() RoutablePrefixes { + var out map[netip.Prefix][]Node + for _, n := range rp.Reachable { + for _, p := range n.Routes { + mak.Set(&out, p, append(out[p], n)) + } + } + for p := range out { + slices.SortFunc(out[p], Node.Compare) + } + return out +} + // Node represents a node in the reachability report. type Node struct { ID tailcfg.NodeID `json:"id"` @@ -71,18 +96,26 @@ type Node struct { Routes []netip.Prefix `json:"routes"` } +// Compare returns an integer comparing two nodes, ordered by their node ID. +// The result will be 0 if n.ID == n2.ID, -1 if n.ID < n2.ID, and +1 if n.ID > n2.ID. +func (n Node) Compare(n2 Node) int { + return cmp.Compare(n.ID, n2.ID) +} + // 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{} +var ( + _ jsonv1.Marshaler = &NodeSet{} + _ jsonv1.Unmarshaler = &NodeSet{} + _ jsonv2.MarshalerTo = &NodeSet{} + _ 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) - }) + nodes := slices.SortedFunc(maps.Values(ns), Node.Compare) return jsonv2.MarshalEncode(enc, nodes) } @@ -100,3 +133,31 @@ func (ns *NodeSet) UnmarshalJSONFrom(dec *jsontext.Decoder) error { } return nil } + +// MarshalJSON implements [jsonv1.Marshaler]. +func (ns *NodeSet) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(ns, jsonv1.DefaultOptionsV1()) +} + +// UnmarshalJSON implements [jsonv1.Unmarshaler]. +func (ns *NodeSet) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, ns, jsonv1.DefaultOptionsV1()) + +} + +// RoutablePrefixes is a map of routers, +// keyed by the network prefix for which they route. +type RoutablePrefixes map[netip.Prefix][]Node + +// Sorted returns an iterator over the map of routers, +// ordered by the network prefix as described in [netip.Prefix.Compare]. +func (rt RoutablePrefixes) Sorted() iter.Seq2[netip.Prefix, []Node] { + return func(yield func(netip.Prefix, []Node) bool) { + prefixes := slices.SortedFunc(maps.Keys(rt), netip.Prefix.Compare) + for _, p := range prefixes { + if !yield(p, rt[p]) { + return + } + } + } +} diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index eea1e62bf..031cc9a6d 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -31,6 +31,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp