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>
277 lines
8.9 KiB
Go
277 lines
8.9 KiB
Go
// 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{}
|
||
|
||
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() {
|
||
markReachable(n)
|
||
continue
|
||
}
|
||
|
||
g.Go(func() error {
|
||
metricPing.Add(1)
|
||
// 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:
|
||
c.vlogf("ping %s (%s): result: %f ms (err: %v)",
|
||
n.addr, n.ID(), pong.LatencySeconds*1000, pong.Err)
|
||
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
|
||
}
|
||
}
|