Files
tailscale/net/routecheck/routecheck.go
T
Simon Law cb7e536804 feature/routecheck,ipn/routecheck: probe reachability in the background
Previously, refreshing the routecheck.Client would probe to generate a
new routecheck.Report, but this method was only wired up to the
LocalAPI and the `tailscale routecheck` command. However, waiting for
a probe to finish before choosing a router would take too long, so we
must keep a regularly updated report to be consulted as necessary.

This patch adds a Start and Close method to the routecheck.Client and
starts it in the background from features/routecheck. To enable this
feature for a given node, set both of the following node attributes:
`client-side-reachability` and `client-side-reachability-routecheck`.

This patch also wires up the RouterTracker.OnRoutersChange hook, which
fires a callback whenever a new network map includes information about
a router node, This signals to the routecheck.Client that it might
need to schedule another probe, if the shape of the routing table has
changed materially.

Updates #17366
Updates tailscale/corp#33033

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

365 lines
11 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 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/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(tailcfg.NodeAttrClientSideReachability) &&
self.HasCap(tailcfg.NodeAttrClientSideReachabilityRouteCheck)
}
// 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 doesnt 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 doesnt 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 // dont 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
}