From 8c714b2ac545a54b315eb774bede09fb9f1a9ecf Mon Sep 17 00:00:00 2001 From: Simon Law Date: Thu, 9 Apr 2026 22:57:09 -0700 Subject: [PATCH] cmd/tailscale/cli: add routecheck command 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/tailscale/cli/cli.go | 2 + cmd/tailscale/cli/netcheck.go | 4 +- cmd/tailscale/cli/routecheck.go | 101 ++++++++++++++++++++++++++++++++ cmd/tailscale/depaware.txt | 2 +- 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 cmd/tailscale/cli/routecheck.go diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 8a2c2b9ef..9f910c514 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -211,6 +211,7 @@ func noDupFlagify(c *ffcli.Command) { var ( fileCmd, sysPolicyCmd, + maybeRoutecheckCmd, maybeWebCmd, maybeDriveCmd, maybeNetlockCmd, @@ -252,6 +253,7 @@ change in the future. 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..18590fcef --- /dev/null +++ b/cmd/tailscale/cli/routecheck.go @@ -0,0 +1,101 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_routecheck + +package cli + +import ( + "context" + "flag" + "fmt" + "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/ipn/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", + Exec: runRoutecheck, + FlagSet: routecheckFlagSet, + } +} + +var routecheckFlagSet = func() *flag.FlagSet { + fs := newFlagSet("routecheck") + fs.BoolVar(&routecheckArgs.force, "force", false, "force probe 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 { + force bool + format string +} + +func runRoutecheck(ctx context.Context, args []string) error { + rp, err := localClient.RouteCheck(ctx, routecheckArgs.force) + 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) + } + + routes := rp.RoutablePrefixes() + + if enc != nil { + out := struct { + Done time.Time `json:"done"` + Routes routecheck.RoutingTable `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.UTC().Format(time.DateTime+"Z07:00")) + fmt.Fprintf(w, "\n %s\t%s\t%s\t", "PREFIX", "IP", "HOSTNAME") + for prefix, nodes := range routes { + for _, n := range nodes { + fmt.Fprintf(w, "\n %s\t%s\t%s\t", prefix, n.Addr, strings.Trim(n.Name, ".")) + } + } + fmt.Fprintln(w) + return nil +} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index b0ec2fc00..e8f861890 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -201,7 +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/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+