This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routecheck
import (
"log"
"tailscale.com/envknob"
)
// Debugging tweakable.
var debugRoutecheck = envknob.RegisterBool("TS_DEBUG_ROUTECHECK")
// Logf calls [Client.Logf] to print to a logger.
// Arguments are handled in the manner of fmt.Printf.
func (c *Client) logf(format string, a ...any) {
if c.Logf != nil {
c.Logf(format, a...)
} else {
log.Printf(format, a...)
}
}
// Vlogf calls [Client.Logf] to print to a logger, only when in debug mode,
// which is when the TS_DEBUG_ROUTECHECK environment variable is set.
// Arguments are handled in the manner of fmt.Printf.
func (c *Client) vlogf(format string, a ...any) {
if c.Verbose || debugRoutecheck() {
c.logf(format, a...)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package peernode contains constants that describe a peer node in the routecheck report.
package peernode
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package peernode
import "fmt"
// Reachability reports whether a peer node is reachable.
type Reachability int
const (
// Unknown is a peer node whose reachability is unknown.
// This peer is new and we have yet to probe it.
Unknown Reachability = iota
// Reachable is a peer node that is reachable from this node.
Reachable
// Unreachable is a peer node that is unreachable from this node.
// This peer is not responding to probes.
Unreachable
)
// String implements [fmt.Stringer].
func (r Reachability) String() string {
switch r {
case Unknown:
return "Unknown"
case Reachable:
return "Reachable"
case Unreachable:
return "Unreachable"
default:
panic(fmt.Sprintf("unknown %#v", r))
}
}
// IsReachable reports if r is [Reachable].
func (r Reachability) IsReachable() bool {
return r == Reachable
}
+297
View File
@@ -0,0 +1,297 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routecheck
import (
"cmp"
"context"
"iter"
"net/netip"
"slices"
"time"
"golang.org/x/sync/errgroup"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/traffic"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tsconst"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
)
var (
metricPing = clientmetric.NewCounter("routecheck_ping")
metricPingError = clientmetric.NewCounter("routecheck_ping_error")
metricPingReachable = clientmetric.NewCounter("routecheck_ping_reachable")
metricPingTimeout = clientmetric.NewCounter("routecheck_ping_timeout")
metricProbe = clientmetric.NewCounter("routecheck_probe")
)
// DefaultTimeout is the default time allowed for a response
// before a peer is considered unreachable.
const DefaultTimeout = tsconst.DefaultPingTimeout
type probed struct {
tailcfg.NodeView
addr netip.Addr
routes []netip.Prefix
}
func (c *Client) probe(ctx context.Context, nodes iter.Seq[probed], limit int, timeout time.Duration) (*Report, error) {
metricProbe.Add(1)
g, ctx := errgroup.WithContext(ctx)
if limit > 0 {
g.SetLimit(limit)
}
var mu syncs.Mutex
r := &Report{}
timestampProbe := func(n probed) {
mu.Lock()
defer mu.Unlock()
mak.Set(&r.LastProbed, n.ID(), time.Now())
}
markReachable := func(n probed) {
mu.Lock()
defer mu.Unlock()
nid := n.ID()
if _, ok := r.Reachable[nid]; !ok {
mak.Set(&r.Reachable, nid, Node{
ID: nid,
Name: n.Name(),
Addr: n.addr,
Routes: n.routes,
})
}
}
// TODO(sfllaw): Since the nodes are sorted by priority,
// where earlier nodes have high traffic-steering scores,
// it should be possible to deprioritize or skip probes
// if there are already enough responses for a particular resource.
// This optimization has not been implemented yet, so all nodes are probed.
for n := range nodes {
// WireGuard-only nodes are assumed to be reachable, since
// we don’t want to probe nodes that don’t understand Disco pings.
//
// We could establish a WireGuard session to probe them,
// which would allow us to exclude nodes that won’t respond,
// but all the other nodes would hold unnecessary session state.
// This would be incredibly rude and could potentially DDOS them.
//
// TODO(sfllaw): Add a mechanism to mark a node as unreachable
// because it fails of establish a new WireGuard connection.
if n.IsWireGuardOnly() {
timestampProbe(n)
markReachable(n)
continue
}
g.Go(func() error {
metricPing.Add(1)
// We record the timestamp of each node’s latest probe
// so we can probe in incremental batches
// and to limit the rate that any given node is pinged.
//
// TODO(sfllaw): We currently record the timestamp
// but haven’t implemented batching or rate-limiting yet.
defer timestampProbe(n)
// TODO(sfllaw): Why did we choose Disco ping instead of TSMP ping?
// After all, a TSMP ping proves that the peer Tailscale node is there
// and that both nodes know each other’s WireGuard keys,
// while a Disco ping only proves that the peer can be found using DERP.
// However, TSMP is wrapped in a long-lived WireGuard connection,
// which is too expensive when generating a reachability report.
// Although different nodes theoretically could share the same Disco key,
// in practice there is a 1:1 mapping between a Disco key and a node key.
//
// TODO(#19670): WireGuard establishes connections with a single round-trip,
// so there is no existing way to confirm that a WireGuard connection
// can be established without burdening the peer with lingering state.
// WireGuard could be extended with a special `handshake_initiation`
// that only verifies that a connection could be established,
// requesting this with a sentinel in `handshake_initiation.mac2`.
// The peer would send a valid but stateless `handshake_response`,
// using a random ephemeral_private key and not record any state.
// See https://www.wireguard.com/protocol/.
switch pong, err := c.ping(ctx, n.addr, tailcfg.PingDisco, timeout); {
case err == context.DeadlineExceeded:
// Ping timed out, so assume that the node is unreachable.
c.vlogf("ping %s (%s): timed out", n.addr, n.ID())
metricPingTimeout.Add(1)
return nil
case err != nil:
// Returning an error would cancel the errgroup.
c.vlogf("ping %s (%s): error: %v", n.addr, n.ID(), err)
metricPingError.Add(1)
return nil
case pong == nil:
c.vlogf("ping %s (%s): error: no response", n.addr, n.ID())
metricPingError.Add(1)
return nil
default:
if pong.Err != "" {
c.vlogf("ping %s (%s): result: %f ms (err: %v)",
n.addr, n.ID(), pong.LatencySeconds*1000, pong.Err)
} else {
c.vlogf("ping %s (%s): result: %f ms",
n.addr, n.ID(), pong.LatencySeconds*1000)
}
metricPingReachable.Add(1)
}
markReachable(n)
return nil
})
}
g.Wait()
r.Done = time.Now()
return r, nil
}
// Probe actively probes the sequence of nodes and returns a reachability [Report].
// If limit is positive, it limits the number of concurrent active probes;
// a limit of zero will ping every node at once.
// A peer is considered unreachable if it doesn’t respond within the timeout.
//
// This function will probe nodes in order, so better candidates should be
// sorted earlier in the sequence. This function may use ordering to skip some probes
// if it has discovered enough reachable peers.
//
// A node’s IPv4 address is preferred, if the current node also supports IPv4.
// A node’s IPv6 is only probed when the current node only supports IPv6.
// In 2026, IPv4 is still more common and more likely to work properly.
func (c *Client) Probe(ctx context.Context, nodes iter.Seq[tailcfg.NodeView], limit int, timeout time.Duration) (*Report, error) {
can4, can6 := supportsIPVersions(c.nb.NodeBackend().Self())
if !can4 && !can6 {
return nil, nil
}
// TODO(sfllaw): Probes should fall back to IPv6, if the IPv4 probe times out
// and IPv6 is also supported by the current node.
addrFor := addrPicker(can4, can6)
var dsts iter.Seq[probed] = func(yield func(probed) bool) {
for n := range nodes {
// Probe one of the tailnet addresses.
addr := addrFor(n)
if !addr.IsValid() {
continue // No valid addresses.
}
if !yield(probed{
NodeView: n,
addr: addr,
routes: routes(n),
}) {
return
}
}
}
return c.probe(ctx, dsts, limit, timeout)
}
// ProbeAllHARouters actively probes all High Availability routers in parallel
// and returns a [Report] that identifies which of these routers are reachable.
// If limit is positive, it limits the number of concurrent active probes;
// a limit of zero will ping every candidate at once.
// A peer is considered unreachable if it doesn’t respond within the timeout.
func (c *Client) ProbeAllHARouters(ctx context.Context, limit int, timeout time.Duration) (*Report, error) {
nm, err := c.waitForNetMap(ctx)
if err != nil {
return nil, err
}
// When a prefix is routed by multiple nodes, we probe those nodes.
// There is no point to probing a router when it is the only choice.
// These nodes are referred to a High Availability (HA) routers.
var nodes []tailcfg.NodeView
for _, rs := range c.RoutersByPrefix() {
if len(rs) <= 1 {
continue
}
nodes = append(nodes, rs...) // Note: this introduces duplicates.
}
// Sort by Node.ID and deduplicate to avoid double-probing.
slices.SortFunc(nodes, func(a, b tailcfg.NodeView) int {
return cmp.Compare(a.ID(), b.ID())
})
nodes = slices.CompactFunc(nodes, func(a, b tailcfg.NodeView) bool {
return a.ID() == b.ID()
})
// Each node should probe starting with the highest scoring node.
// We use rendezvous hashing to break ties in a consistent manner
// while still preventing swarming.
ss := traffic.ScoresFor(nm.SelfNode.ID(), nodes)
ss.SortNodes(nodes)
return c.Probe(ctx, slices.Values(nodes), limit, timeout)
}
// Ping returns the result of a ping to the peer handling the given IP.
// It returns a [context.DeadlineExceeded] error if the peer doesn’t respond within the timeout.
func (c *Client) ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType, timeout time.Duration) (*ipnstate.PingResult, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ch := make(chan *ipnstate.PingResult, 1)
c.pinger.Ping(ip, pingType, 0, func(pr *ipnstate.PingResult) {
select {
case ch <- pr:
default:
}
})
select {
case pr := <-ch:
return pr, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// SupportsIPVersions reports whether n supports IPv4 or IPv6.
func supportsIPVersions(n tailcfg.NodeView) (can4, can6 bool) {
if !n.Valid() {
return false, false
}
for _, ip := range n.Addresses().All() {
addr := ip.Addr()
if addr.Is4() {
can4 = true
} else if addr.Is6() {
can6 = true
}
if can4 && can6 {
break
}
}
return can4, can6
}
func addrPicker(can4, can6 bool) func(n tailcfg.NodeView) netip.Addr {
// TODO(sfllaw): Picking just the one address is a little brittle
// because this picks just one address and there’s no fallback facility.
// [Client.Probe] is the caller that will need refactoring.
return func(n tailcfg.NodeView) netip.Addr {
var zero netip.Addr
for _, ip := range n.Addresses().All() {
// Find a compatible IP address.
addr := ip.Addr()
if can4 && addr.Is4() {
return addr
}
if can6 && addr.Is6() {
return addr
}
}
return zero
}
}
+166
View File
@@ -0,0 +1,166 @@
// 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
}
}
}
}
+365
View File
@@ -0,0 +1,365 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package routecheck performs status checks for routes from the current host.
package routecheck
import (
"context"
"errors"
"fmt"
"net/netip"
"sync/atomic"
"time"
"tailscale.com/envknob"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
)
var (
metricNeedsRefresh = clientmetric.NewCounter("routecheck_needs_refresh")
metricRefresh = clientmetric.NewCounter("routecheck_refresh")
)
// DebugForceClientSideReachabilityRoutecheck reports whether routecheck should be forced on or off.
// If the TS_DEBUG_FORCE_CLIENT_SIDE_REACHABILITY_ROUTECHECK environment variable is true,
// then routecheck is forced on. If it is false, then routecheck is forced off.
// If unset, then the client respects the client-side-reachability and
// client-side-reachability-routecheck node attributes.
var DebugForceClientSideReachabilityRoutecheck = envknob.RegisterOptBool("TS_DEBUG_FORCE_CLIENT_SIDE_REACHABILITY_ROUTECHECK")
// IsEnabled reports whether routecheck probing has been enabled for this client.
func IsEnabled(self tailcfg.NodeView) bool {
if v, ok := DebugForceClientSideReachabilityRoutecheck().Get(); ok {
return v // forced
}
if !self.Valid() {
return false
}
// TODO(sfllaw): We intend to eventually enable this behaviour by default.
return self.HasCap(nodecap.ClientSideReachability) &&
self.HasCap(nodecap.ClientSideReachabilityRouteCheck)
}
// Client generates Reports describing the result of both passive and active
// reachability probing.
type Client struct {
// Verbose enables verbose logging.
Verbose bool
// Logf optionally specifies where to log to.
// If nil, log.Printf is used.
Logf logger.Logf
// These elements are read-only after initialization.
nb NodeBackender
nm NetMapper
pinger Pinger
ctx context.Context
cancel context.CancelFunc
// needsRefresh is sent a message by [Client.NeedsRefresh]
// to signal that a new report is needed.
// This message is received by the goroutine spawned by [Client.Start]
// which probes the appropriate routers to compile a new [Client.report].
// This channel doesn’t need to be closed because the goroutine is canceled by ctx.
needsRefresh chan struct{}
report atomic.Pointer[Report] // needsRefresh signals that this needs refreshing
// HasNetMap is a channel that can be closed to wake up goroutines
// waiting for the netmap received after connecting to the control plane.
// This channel gets swapped out for a new one whenever it is closed,
// to handle disconnecting and reconnecting to the control plane.
hasNetMap atomic.Pointer[chan struct{}]
}
// NetMapper is the interface that returns the current [netmap.NetworkMap].
type NetMapper interface {
// NetMapNoPeers returns the latest cached network map received from
// controlclient WITHOUT a freshly-built Peers slice.
//
// On a tailnet with frequent peer churn the cached netmap's Peers slice
// can be stale relative to the live per-node-backend peers map; non-Peers
// fields (SelfNode, DNS, PacketFilter, capabilities, ...) are always
// current. Use this for any caller that does not need to iterate Peers,
// since it's O(1) regardless of tailnet size.
//
// Returns nil if no network map has been received yet.
NetMapNoPeers() *netmap.NetworkMap
// NetMapWithPeers returns the latest network map with the Peers slice
// populated.
//
// Currently this is the same as [LocalBackend.NetMapNoPeers]: the cached
// netmap's Peers slice may be stale relative to the live per-node-backend
// peers map. A follow-up change will switch this method to return a
// freshly-built netmap with up-to-date Peers, at O(N) cost per call.
// Callers that genuinely need the up-to-date peer set should use this
// method (and document why) so the upcoming change reaches them.
//
// Returns nil if no network map has been received yet.
NetMapWithPeers() *netmap.NetworkMap
}
// NodeBackender is the interface that returns the current [NodeBackend].
type NodeBackender interface {
NodeBackend() NodeBackend
}
// NodeBackend is an interface to query the current node and its peers.
//
// It is not a snapshot in time but is locked to a particular node.
type NodeBackend interface {
// Self returns the current node.
Self() tailcfg.NodeView
// Peers returns all the current peers.
Peers() []tailcfg.NodeView
}
// Pinger is the interface that wraps the [tailscale.com/ipn/ipnlocal.LocalBackend.Ping] method.
type Pinger interface {
Ping(ip netip.Addr, pingType tailcfg.PingType, size int, cb func(*ipnstate.PingResult))
}
// NewClient returns a client that probes its peers using this LocalBackend.
func NewClient(ctx context.Context, logf logger.Logf, nb NodeBackender, nm NetMapper, pinger Pinger) (*Client, error) {
if nb == nil {
return nil, errors.New("NodeBackender must be set")
}
if nm == nil {
return nil, errors.New("NetMapper must be set")
}
if pinger == nil {
return nil, errors.New("Pinger must be set")
}
ctx, cancel := context.WithCancel(ctx)
c := &Client{
Logf: logf,
nb: nb,
nm: nm,
pinger: pinger,
ctx: ctx,
cancel: cancel,
needsRefresh: make(chan struct{}, 1), // debounce using buffer of 1
}
c.hasNetMap.Store(new(make(chan struct{})))
return c, nil
}
// NotifyNetMapAvailable wakes up goroutines that have been waiting for the
// non-nil network map that the control plane sends after reconnecting.
func (c *Client) NotifyNetMapAvailable() {
if nm := c.nm.NetMapNoPeers(); nm == nil {
return // client disconnected
}
var nextCh *chan struct{}
for {
ch := c.hasNetMap.Load()
if ch == nil || *ch == nil {
return // Client has been Closed
}
if nextCh == nil {
nextCh = new(make(chan struct{})) // prepare for next non-nil netmap
}
if c.hasNetMap.CompareAndSwap(ch, nextCh) {
close(*ch)
return
}
}
}
func (c *Client) waitForNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
for {
ch := c.hasNetMap.Load()
if ch == nil || *ch == nil {
return nil, errors.New("routecheck client closed")
}
if nm := c.nm.NetMapNoPeers(); nm != nil {
return nm, nil
}
select {
case <-c.ctx.Done(): // closed
return nil, c.ctx.Err()
case <-ctx.Done():
return nil, ctx.Err()
case <-*ch: // woken up by NotifyNetMapAvailable
}
}
}
// Refresh generates and returns a new reachability report, after caching it in [Client.Report].
// A peer is considered unreachable if it doesn’t respond within the timeout.
// If the cached Client.Report is newer than the report that it just generated,
// Refresh will return the cached report instead of clobbering it report.
func (c *Client) Refresh(ctx context.Context, timeout time.Duration) (*Report, error) {
metricRefresh.Add(1)
c.vlogf("refreshing report")
r, err := c.ProbeAllHARouters(ctx, 5, timeout)
if err != nil {
return nil, fmt.Errorf("error refreshing routers: %w", err)
}
for {
saved := c.report.Load()
if saved != nil && !saved.Done.Before(r.Done) {
return saved, nil // don’t clobber newer reports
}
if c.report.CompareAndSwap(saved, r) { // retry if a concurrent Refresh stored first
c.vlogf("saved new report at %v", r.Done)
return r, nil
}
}
}
// NeedsRefresh signals the need for a [Client.Refresh] to probe for a new report,
// which will be done in the background by [Client.Start].
func (c *Client) NeedsRefresh() {
if !IsEnabled(c.nb.NodeBackend().Self()) {
return
}
select {
case c.needsRefresh <- struct{}{}:
metricNeedsRefresh.Add(1)
c.vlogf("report needs refresh")
default:
// needsRefresh has already been raised, so debounce.
}
}
// NeedsIncrRefresh signals the need for an incremental probe for a new report,
// because routers have been added, modified, or removed,
// which will be done in the background by [Client.Start].
func (c *Client) NeedsIncrRefresh(added, modified, removed []tailcfg.NodeID) {
// TODO(sfllaw): Currently, this refreshes everything.
c.NeedsRefresh()
}
// WatchForNetMonRebind watches the network monitor
// for a signal that the sockets need to be rebound,
// which implies that the cached report needs to be refreshed.
// See [netmon.ChangeDelta.RebindLikelyRequired].
func (c *Client) WatchForNetMonRebind(delta netmon.ChangeDelta) {
if delta.RebindLikelyRequired {
c.NeedsRefresh()
}
}
// Start runs periodic probes that compile routecheck reports.
// Use [Client.Close] to stop probing.
// Returns an error if the client has already been closed.
func (c *Client) Start() error {
if c.ctx.Err() != nil {
return c.ctx.Err()
}
needsBootstrap := true
for {
select {
case <-c.needsRefresh:
nm := c.nm.NetMapWithPeers()
if nm == nil {
// There is no netmap: clear the cached report.
c.report.Store(nil)
needsBootstrap = true
continue
}
if needsBootstrap {
r := c.bootstrap(nm)
c.report.Store(r)
needsBootstrap = false
}
// TODO(sfllaw): Examine the shape of the overlapping
// routers and only probe if the routing table has
// changed sufficiently. For instance, a new router has
// come online or a router has been removed or a set of
// routers no longer overlap.
if _, err := c.Refresh(c.ctx, DefaultTimeout); err != nil {
c.logf("%v", err)
}
case <-c.ctx.Done(): // closed
return nil
}
}
}
// bootstrap assumes that nodes that are connected to the control plane are reachable,
// while waiting for the first probe to finish.
//
// This function requires a netmap with peers.
func (c *Client) bootstrap(nm *netmap.NetworkMap) *Report {
if nm == nil {
return nil
}
can4, can6 := supportsIPVersions(c.nb.NodeBackend().Self())
if !can4 && !can6 {
return nil
}
addrFor := addrPicker(can4, can6)
var r Report
for _, nodes := range GroupRoutersByPrefix(nm.Peers) {
if len(nodes) <= 1 {
continue // Not an overlapping router
}
// TODO(sfllaw): Instead of trusting the Node.Online flag,
// which actually represents whether the node is connected
// to the control plane and not that it is reachable,
// we should cache reachability alongside the cached netmap
// long enough to survive a restart or a brief disconnection.
for _, n := range nodes {
if !n.Online().Get() {
continue // Not connected to the control plane.
}
addr := addrFor(n)
if !addr.IsValid() {
continue // No valid addresses.
}
mak.Set(&r.Reachable, n.ID(), Node{
ID: n.ID(),
Name: n.Name(),
Addr: addr,
Routes: routes(n),
})
}
}
r.Done = time.Now()
c.vlogf("bootstrapped report from netmap at %v", r.Done)
return &r
}
// Close immediately stops all active probes.
func (c *Client) Close() error {
if c == nil {
return nil
}
if c.cancel != nil {
c.cancel()
}
hasNetMap := c.hasNetMap.Swap(nil) // clear before waking anything up
if hasNetMap != nil && *hasNetMap != nil {
close(*hasNetMap) // wake waitForNetMap
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routecheck
import (
"net/netip"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"tailscale.com/util/mak"
)
// RoutersByPrefix represents a map of nodes grouped by the subnet that they route.
// Nodes that route for /0 prefixes are exit nodes, their subnet is the Internet.
// The result omits any prefix that is one of a node’s local addresses.
//
// 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.
type RoutersByPrefix map[netip.Prefix][]tailcfg.NodeView
// RoutersByPrefix returns a map of nodes grouped by the subnet that they route.
// See [RoutersByPrefix] for more detail.
func (c *Client) RoutersByPrefix() RoutersByPrefix {
return GroupRoutersByPrefix(c.nb.NodeBackend().Peers())
}
// GroupRoutersByPrefix returns a map of nodes grouped by the subnet that they route.
// See [RoutersByPrefix] for more detail.
func GroupRoutersByPrefix(nodes []tailcfg.NodeView) RoutersByPrefix {
var routers RoutersByPrefix
for _, n := range nodes {
for _, pfx := range routes(n) {
mak.Set(&routers, pfx, append(routers[pfx], n))
}
}
return routers
}
// Routes returns a slice of subnets that the given node will route.
// If the node is an exit node, the result will contain at least one /0 prefix.
// If the node is a subnet router, the result will contain a smaller prefix.
// The result omits any prefix that is one of the node’s local addresses.
func routes(n tailcfg.NodeView) []netip.Prefix {
var routes []netip.Prefix
for _, pfx := range n.AllowedIPs().All() {
// Routers never forward their own local addresses.
if views.SliceContains(n.Addresses(), pfx) {
continue
}
routes = append(routes, pfx)
}
return routes
}