The routecheck package parallels the netcheck package, where the former checks routes and routers while the latter checks networks. Like netcheck, it compiles reports for other systems to consume. Historically, the client has never known whether a peer is actually reachable. Most of the time this doesn’t matter, since the client will want to establish a WireGuard tunnel to any given destination. However, if the client needs to choose between two or more nodes, then it should try to choose a node that it can reach. Suggested exit nodes are one such example, where the client filters out any nodes that aren’t connected to the control plane. Sometimes an exit node will get disconnected from the control plane: when the network between the two is unreliable or when the exit node is too busy to keep its control connection alive. In these cases, Control disables the Node.Online flag for the exit node and broadcasts this across the tailnet. Arguably, the client should never have relied on this flag, since it only makes sense in the admin console. This patch implements an initial routecheck client that can probe every node that your client knows about. You should not ping scan your visible tailnet, this method is for debugging only. This patch also introduces a new OnNetMapToggle hook, which fires when the netmap transitions from nil to non-nil, or vice versa. This happens either when the client receives its first MapResponse after connecting to the control plane, or when it clears the netmap while it is disconnecting. Routecheck uses this to wait for a valid netmap so it knows which peers to probe. Updates #17366 Updates tailscale/corp#33033 Signed-off-by: Simon Law <sfllaw@tailscale.com>
166 lines
4.6 KiB
Go
166 lines
4.6 KiB
Go
// 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"
|
|
"net/netip"
|
|
"sync/atomic"
|
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/types/netmap"
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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(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")
|
|
}
|
|
c := &Client{
|
|
Logf: logf,
|
|
nb: nb,
|
|
nm: nm,
|
|
pinger: pinger,
|
|
}
|
|
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(nm *netmap.NetworkMap) {
|
|
if 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 <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-*ch: // woken up by NotifyNetMapAvailable
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close immediately stops all active probes.
|
|
func (c *Client) Close() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
|
|
ch := c.hasNetMap.Swap(nil) // clear before waking anything up
|
|
if ch != nil && *ch != nil {
|
|
close(*ch)
|
|
}
|
|
|
|
return nil
|
|
}
|