Update
This commit is contained in:
1 parent
3530afb854
commit
6903fcff27
2146 files changed
+112256
-190703
No files matched your search
+32
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user