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>
167 lines
5.0 KiB
Go
167 lines
5.0 KiB
Go
// 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 isn’t 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 entry’s
|
||
// presence doesn’t 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 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"`
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
}
|