Files
tailscale/net/routecheck/report.go
T
Simon LawandSimon Law 932260511e ipn/ipnlocal: use routecheck reports to make exit node suggestions
Now that the routecheck subsystem is continuously collecting
reachability reports in the background, we can add a hook to
LocalBackend for fetching its report. That allows
suggestExitNodeUsingTrafficSteering to consult that report when
disqualifying candidates, instead of blocking on an immediate probe.

Exit node suggestions will only consult the report when the
`client-side-reachability` and `client-side-reachability-routecheck`
node attributes are both set on the current node.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00

167 lines
5.0 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"
"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/net/routecheck/peernode"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
)
var (
metricReport = clientmetric.NewCounter("routecheck_report")
)
// Report returns the latest reachability report.
// It returns nil if a report isnt available, which happens during initialization.
func (c *Client) Report() *Report {
metricReport.Add(1)
return c.report.Load()
}
// 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
}
// IsReachable reports whether a peer is reachable by the current node.
func (rp Report) IsReachable(id tailcfg.NodeID) peernode.Reachability {
// TODO(sfllaw): We should actually track all routers and consider the
// absence of a router in the report as it being recently added for
// consideration, so it is unknown. Then we should positively track
// whether a node was reachable or not.
_, k := rp.Reachable[id]
if k {
return peernode.Reachable
}
return peernode.Unknown
}
// 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
}
}
}
}