Files
tailscale/net/routecheck/report.go
T
Simon LawandGitHub 2d6844c565 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>
2026-06-01 11:50:24 -07:00

164 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routecheck
import (
"cmp"
"context"
"iter"
"maps"
"net/netip"
"slices"
"time"
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 (
metricReport = clientmetric.NewCounter("routecheck_report")
)
// Report returns the latest reachability report.
// Returns nil if a report isnt available, which happens during initialization.
func (c *Client) Report() *Report {
metricReport.Add(1)
nm := c.nm.NetMapNoPeers()
if nm == nil {
return nil // The report wasnt available.
}
// TODO(sfllaw): Return the latest snapshot produced by background probing.
r, err := c.Refresh(context.TODO(), DefaultTimeout)
if err != nil {
c.logf("%v", err)
}
return r
}
// Report contains the result of a single routecheck.
type Report struct {
// Done is the time when the report was finished.
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 NodeSet `json:"reachable"`
// LastProbed tracks the last time a given node was probed.
// This is used to rate-limit reachability probing, so an entrys
// presence doesnt imply that it is reachable.
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"`
// 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 `json:"name"`
// Addr is the IP address that was probed.
Addr netip.Addr `json:"addr"`
// Routes are the subnets that the node will route.
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 (
_ 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), Node.Compare)
return jsonv2.MarshalEncode(enc, nodes)
}
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (ns *NodeSet) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var nodes []Node
if err := jsonv2.UnmarshalDecode(dec, &nodes); err != nil {
return err
}
if *ns == nil {
*ns = make(NodeSet, len(nodes))
}
for _, n := range nodes {
(*ns)[n.ID] = n
}
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
}
}
}
}