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 <sfllaw@tailscale.com>
This commit is contained in:
Simon Law
2026-06-01 11:50:24 -07:00
committed by GitHub
parent da51072b98
commit 2d6844c565
10 changed files with 198 additions and 9 deletions
+66 -5
View File
@@ -6,6 +6,7 @@ package routecheck
import (
"cmp"
"context"
"iter"
"maps"
"net/netip"
"slices"
@@ -13,9 +14,11 @@ import (
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 prefixs 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 arent 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
}
}
}
}