WIP: rebase fork onto upstream/main (v1.103.0) #15
@@ -372,6 +372,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/portmapper from tailscale.com/feature/portmapper+
|
||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/feature/portmapper+
|
||||
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/net/routecheck from tailscale.com/feature/routecheck
|
||||
tailscale.com/net/routetable from tailscale.com/doctor/routetable
|
||||
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
|
||||
@@ -379,7 +380,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/tsaddr from tailscale.com/client/web+
|
||||
tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/net/routecheck"
|
||||
)
|
||||
|
||||
// NodeBackender is a shim between [ipnext.Host] and [routecheck.NodeBackender].
|
||||
type nodeBackender struct{ ipnext.Host }
|
||||
|
||||
var _ routecheck.NodeBackender = nodeBackender{}
|
||||
|
||||
func (nb nodeBackender) NodeBackend() routecheck.NodeBackend {
|
||||
return nb.Host.NodeBackend()
|
||||
}
|
||||
@@ -12,6 +12,77 @@
|
||||
// establish a WireGuard session.
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/net/routecheck"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// FeatureName is the name of the feature implemented by this package.
|
||||
// It is also the [extension] name and the log prefix.
|
||||
const featureName = "routecheck"
|
||||
|
||||
func init() {
|
||||
// TODO(sfllaw): Initialize the new routecheck package.
|
||||
ipnext.RegisterExtension(featureName, func(logf logger.Logf, b ipnext.SafeBackend) (ipnext.Extension, error) {
|
||||
return &Extension{
|
||||
logf: logger.WithPrefix(logf, featureName+": "),
|
||||
backend: b,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Extension implements the [ipnext.Extension] interface.
|
||||
type Extension struct {
|
||||
Client *routecheck.Client
|
||||
|
||||
logf logger.Logf
|
||||
backend ipnext.SafeBackend
|
||||
nb nodeBackender
|
||||
nm routecheck.NetMapper
|
||||
}
|
||||
|
||||
var _ ipnext.Extension = new(Extension)
|
||||
|
||||
// Name implements the [ipnext.Extension.Name] interface method.
|
||||
func (e *Extension) Name() string {
|
||||
return featureName
|
||||
}
|
||||
|
||||
// Init implements the [ipnext.Extension.Init] interface method.
|
||||
func (e *Extension) Init(h ipnext.Host) error {
|
||||
e.nb = nodeBackender{h}
|
||||
|
||||
nm, ok := e.backend.(routecheck.NetMapper)
|
||||
if !ok {
|
||||
return fmt.Errorf("backend %T does not implement routecheck.NetMapper", e.backend)
|
||||
}
|
||||
e.nm = nm
|
||||
|
||||
pinger := e.backend.Sys().Engine.Get()
|
||||
|
||||
c, err := routecheck.NewClient(e.logf, e.nb, e.nm, pinger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Client = c
|
||||
|
||||
h.Hooks().OnNetMapToggle.Add(e.onNetMapToggle)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown implements the [ipnext.Extension.Shutdown] interface method.
|
||||
func (e *Extension) Shutdown() error {
|
||||
err := e.Client.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Extension) onNetMapToggle(nm *netmap.NetworkMap) {
|
||||
if nm == nil {
|
||||
return
|
||||
}
|
||||
e.Client.NotifyNetMapAvailable(nm)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/mapx"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/wgengine/filter"
|
||||
)
|
||||
@@ -375,6 +376,12 @@ type Hooks struct {
|
||||
// is created. It is called with the LocalBackend locked.
|
||||
NewControlClient feature.Hooks[NewControlClientCallback]
|
||||
|
||||
// OnNetMapToggle is called (with LocalBackend.mu held) when the network map
|
||||
// is toggled from nil to non-nil, or non-nil to nil. This usually happens
|
||||
// when the client connects to the control plane and receives the initial MapResponse,
|
||||
// or when the client disconnects and the network map is cleared.
|
||||
OnNetMapToggle feature.Hooks[func(*netmap.NetworkMap)]
|
||||
|
||||
// OnSelfChange is called (with LocalBackend.mu held) when the self node
|
||||
// changes, including changing to nothing (an invalid view).
|
||||
OnSelfChange feature.Hooks[func(tailcfg.NodeView)]
|
||||
@@ -465,10 +472,16 @@ type FilterHooks struct {
|
||||
//
|
||||
// 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
|
||||
|
||||
// AppendMatchingPeers appends all peers that match the predicate
|
||||
// to the base slice and returns it.
|
||||
AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView
|
||||
|
||||
// Peers returns all the current peers.
|
||||
Peers() []tailcfg.NodeView
|
||||
|
||||
// PeerCaps returns the capabilities that src has to this node.
|
||||
PeerCaps(src netip.Addr) tailcfg.PeerCapMap
|
||||
|
||||
|
||||
@@ -6965,7 +6965,8 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
}()
|
||||
}
|
||||
|
||||
oldSelf := b.currentNode().NetMap().SelfNodeOrZero()
|
||||
oldNetMap := b.currentNode().NetMap()
|
||||
oldSelf := oldNetMap.SelfNodeOrZero()
|
||||
|
||||
b.dialer.SetNetMap(nm)
|
||||
if ns, ok := b.sys.Netstack.GetOK(); ok {
|
||||
@@ -7049,6 +7050,12 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
|
||||
}
|
||||
|
||||
if oldNetMap != nm && (oldNetMap == nil || nm == nil) {
|
||||
for _, f := range b.extHost.Hooks().OnNetMapToggle {
|
||||
f(nm)
|
||||
}
|
||||
}
|
||||
|
||||
if !oldSelf.Equal(nm.SelfNodeOrZero()) {
|
||||
for _, f := range b.extHost.Hooks().OnSelfChange {
|
||||
f(nm.SelfNode)
|
||||
|
||||
@@ -160,6 +160,7 @@ func (nb *nodeBackend) Context() context.Context {
|
||||
return nb.ctx
|
||||
}
|
||||
|
||||
// Self returns the current node.
|
||||
func (nb *nodeBackend) Self() tailcfg.NodeView {
|
||||
nb.mu.Lock()
|
||||
defer nb.mu.Unlock()
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/clientmetric"
|
||||
)
|
||||
|
||||
var (
|
||||
metricReport = clientmetric.NewCounter("routecheck_report")
|
||||
)
|
||||
|
||||
// Report returns the latest reachability report.
|
||||
// Returns nil if a report isn’t available, which happens during initialization.
|
||||
func (c *Client) Report() *Report {
|
||||
metricReport.Add(1)
|
||||
nm := c.nm.NetMapNoPeers()
|
||||
if nm == nil {
|
||||
return nil // The report wasn’t available.
|
||||
}
|
||||
|
||||
// TODO(sfllaw): Return the latest snapshot produced by background probing.
|
||||
r, err := c.ProbeAllHARouters(context.TODO(), 5, DefaultTimeout)
|
||||
if err != nil {
|
||||
c.logf("reachability report error: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Report contains the result of a single routecheck.
|
||||
type Report struct {
|
||||
// Done is the time when the report was finished.
|
||||
Done time.Time
|
||||
|
||||
// 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 map[tailcfg.NodeID]Node
|
||||
}
|
||||
|
||||
// Node represents a node in the reachability report.
|
||||
type Node struct {
|
||||
ID tailcfg.NodeID
|
||||
|
||||
// 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
|
||||
|
||||
// Addr is the IP address that was probed.
|
||||
Addr netip.Addr
|
||||
|
||||
// Routes are the subnets that the node will route.
|
||||
Routes []netip.Prefix
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package routecheck_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
gcmp "github.com/google/go-cmp/cmp"
|
||||
gcmpopts "github.com/google/go-cmp/cmp/cmpopts"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/routecheck"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
func TestReport(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
init bool // true before the netmap has been loaded
|
||||
peers []tailcfg.NodeView
|
||||
gone []tailcfg.NodeID // cannot ping these nodes
|
||||
want []tailcfg.NodeID // Report.Reachable nodes
|
||||
}{
|
||||
{
|
||||
name: "before-netmap",
|
||||
init: true,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no-peers",
|
||||
peers: []tailcfg.NodeView{},
|
||||
want: []tailcfg.NodeID{},
|
||||
},
|
||||
{
|
||||
name: "no-routers",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
},
|
||||
want: []tailcfg.NodeID{},
|
||||
},
|
||||
{
|
||||
name: "no-choice",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(11, withName("exit11"), withExitRoutes()),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
},
|
||||
want: []tailcfg.NodeID{},
|
||||
},
|
||||
{
|
||||
name: "all-good",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(11, withName("exit11"), withExitRoutes()),
|
||||
makeNode(12, withName("exit12"), withExitRoutes()),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
makeNode(22, withName("subnet22"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
},
|
||||
want: []tailcfg.NodeID{11, 12, 21, 22},
|
||||
},
|
||||
{
|
||||
name: "none-good",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(11, withName("exit11"), withExitRoutes()),
|
||||
makeNode(12, withName("exit12"), withExitRoutes()),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
makeNode(22, withName("subnet22"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
},
|
||||
gone: []tailcfg.NodeID{11, 12, 21, 22},
|
||||
want: []tailcfg.NodeID{},
|
||||
},
|
||||
{
|
||||
name: "some-good",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(11, withName("exit11"), withExitRoutes()),
|
||||
makeNode(12, withName("exit12"), withExitRoutes()),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
makeNode(22, withName("subnet22"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
},
|
||||
gone: []tailcfg.NodeID{11, 22},
|
||||
want: []tailcfg.NodeID{12, 21},
|
||||
},
|
||||
} {
|
||||
makeDB := func(nodes []tailcfg.NodeView) map[tailcfg.NodeID]routecheck.Node {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
db := make(map[tailcfg.NodeID]routecheck.Node)
|
||||
for _, n := range nodes {
|
||||
db[n.ID()] = routecheck.Node{
|
||||
ID: n.ID(),
|
||||
Name: n.Name(),
|
||||
Addr: n.Addresses().At(0).Addr(),
|
||||
Routes: n.AllowedIPs().AsSlice()[2:],
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
cmpDiff := func(want, got any) string {
|
||||
return gcmp.Diff(want, got,
|
||||
gcmpopts.EquateComparable(netip.Addr{}, netip.Prefix{}))
|
||||
}
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// The backend is initialized without a NetMap.
|
||||
b := newStubBackend(tailcfg.NodeView{}, nil, withGone(tt.gone...))
|
||||
if !tt.init {
|
||||
self := makeNode(99, withName("self"))
|
||||
b = newStubBackend(self, tt.peers, withGone(tt.gone...))
|
||||
}
|
||||
c, err := routecheck.NewClient(t.Logf, b, b, b)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := c.Report()
|
||||
now := time.Now() // synctest will freeze time.
|
||||
|
||||
var want *routecheck.Report
|
||||
peers := makeDB(tt.peers)
|
||||
if !tt.init {
|
||||
want = &routecheck.Report{
|
||||
Done: now,
|
||||
}
|
||||
for _, nid := range tt.want {
|
||||
mak.Set(&want.Reachable, nid, peers[nid])
|
||||
}
|
||||
}
|
||||
|
||||
if diff := cmpDiff(want, got); diff != "" {
|
||||
t.Errorf("-want +got:\n%s", diff)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutersByPrefix(t *testing.T) {
|
||||
type routersByPrefix map[netip.Prefix][]tailcfg.NodeID
|
||||
simplify := func(rs routecheck.RoutersByPrefix) routersByPrefix {
|
||||
out := make(routersByPrefix, len(rs))
|
||||
for p, ns := range rs {
|
||||
for _, n := range ns {
|
||||
out[p] = append(out[p], n.ID())
|
||||
}
|
||||
slices.Sort(out[p])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
peers []tailcfg.NodeView
|
||||
want routersByPrefix
|
||||
}{
|
||||
{
|
||||
name: "no-peers",
|
||||
peers: []tailcfg.NodeView{},
|
||||
want: routersByPrefix{},
|
||||
},
|
||||
{
|
||||
name: "no-routers",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
},
|
||||
want: routersByPrefix{},
|
||||
},
|
||||
{
|
||||
name: "one-exit-node",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(11, withName("exit11"), withExitRoutes()),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"): {11},
|
||||
netip.MustParsePrefix("::/0"): {11},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "overlapping-exit-nodes",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(11, withName("exit11"), withExitRoutes()),
|
||||
makeNode(12, withName("exit12"), withExitRoutes()),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"): {11, 12},
|
||||
netip.MustParsePrefix("::/0"): {11, 12},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "one-subnet-router",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("192.168.1.0/24"): {21},
|
||||
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "overlapping-subnet-routers",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
makeNode(22, withName("subnet22"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("192.168.1.0/24"): {21, 22},
|
||||
netip.MustParsePrefix("2002:c000:0100::/48"): {21, 22},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disjoint-subnet-routers",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48"))),
|
||||
makeNode(22, withName("subnet22"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("192.168.1.0/24"): {21},
|
||||
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
|
||||
netip.MustParsePrefix("192.168.2.0/24"): {22},
|
||||
netip.MustParsePrefix("2002:c000:0200::/48"): {22},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple-routes",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48")),
|
||||
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
|
||||
makeNode(22, withName("subnet22"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48")),
|
||||
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48"))),
|
||||
makeNode(23, withName("subnet23"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48")),
|
||||
withRoutes(netip.MustParsePrefix("192.168.4.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0400::/48"))),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("192.168.1.0/24"): {21},
|
||||
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
|
||||
netip.MustParsePrefix("192.168.2.0/24"): {21, 22},
|
||||
netip.MustParsePrefix("2002:c000:0200::/48"): {21, 22},
|
||||
netip.MustParsePrefix("192.168.3.0/24"): {22, 23},
|
||||
netip.MustParsePrefix("2002:c000:0300::/48"): {22, 23},
|
||||
netip.MustParsePrefix("192.168.4.0/24"): {23},
|
||||
netip.MustParsePrefix("2002:c000:0400::/48"): {23},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "both-exit-nodes-and-routers",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(11, withName("exit11"), withExitRoutes()),
|
||||
makeNode(12, withName("exit12"), withExitRoutes()),
|
||||
makeNode(21, withName("subnet21"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48")),
|
||||
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
|
||||
makeNode(22, withName("subnet22"),
|
||||
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48")),
|
||||
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48"))),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"): {11, 12},
|
||||
netip.MustParsePrefix("::/0"): {11, 12},
|
||||
netip.MustParsePrefix("192.168.1.0/24"): {21},
|
||||
netip.MustParsePrefix("2002:c000:0100::/48"): {21},
|
||||
netip.MustParsePrefix("192.168.2.0/24"): {21, 22},
|
||||
netip.MustParsePrefix("2002:c000:0200::/48"): {21, 22},
|
||||
netip.MustParsePrefix("192.168.3.0/24"): {22},
|
||||
netip.MustParsePrefix("2002:c000:0300::/48"): {22},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed-nodes",
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNode(1, withName("peer1")),
|
||||
makeNode(31, withName("router31"),
|
||||
withExitRoutes(),
|
||||
withRoutes(netip.MustParsePrefix("192.168.1.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0100::/48")),
|
||||
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48"))),
|
||||
makeNode(32, withName("router32"),
|
||||
withExitRoutes(),
|
||||
withRoutes(netip.MustParsePrefix("192.168.2.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0200::/48")),
|
||||
withRoutes(netip.MustParsePrefix("192.168.3.0/24")),
|
||||
withRoutes(netip.MustParsePrefix("2002:c000:0300::/48"))),
|
||||
},
|
||||
want: routersByPrefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"): {31, 32},
|
||||
netip.MustParsePrefix("::/0"): {31, 32},
|
||||
netip.MustParsePrefix("192.168.1.0/24"): {31},
|
||||
netip.MustParsePrefix("2002:c000:0100::/48"): {31},
|
||||
netip.MustParsePrefix("192.168.2.0/24"): {31, 32},
|
||||
netip.MustParsePrefix("2002:c000:0200::/48"): {31, 32},
|
||||
netip.MustParsePrefix("192.168.3.0/24"): {32},
|
||||
netip.MustParsePrefix("2002:c000:0300::/48"): {32},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
self := makeNode(99, withName("self"))
|
||||
b := newStubBackend(self, tt.peers)
|
||||
c, err := routecheck.NewClient(t.Logf, b, b, b)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := simplify(c.RoutersByPrefix())
|
||||
if !maps.EqualFunc(got, tt.want, slices.Equal) {
|
||||
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type nodeOptFunc func(*tailcfg.Node)
|
||||
|
||||
func makeNode(id tailcfg.NodeID, opts ...nodeOptFunc) tailcfg.NodeView {
|
||||
addresses := []netip.Prefix{
|
||||
netip.MustParsePrefix(fmt.Sprintf("192.168.0.%d/32", id)),
|
||||
netip.MustParsePrefix(fmt.Sprintf("fd7a:115c:a1e0::%d/128", id)),
|
||||
}
|
||||
node := &tailcfg.Node{
|
||||
ID: id,
|
||||
StableID: tailcfg.StableNodeID(fmt.Sprintf("stable%d", id)),
|
||||
Name: fmt.Sprintf("node%d", id),
|
||||
Online: new(true),
|
||||
MachineAuthorized: true,
|
||||
HomeDERP: int(id),
|
||||
Addresses: addresses,
|
||||
AllowedIPs: addresses,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(node)
|
||||
}
|
||||
return node.View()
|
||||
}
|
||||
|
||||
func withExitRoutes() nodeOptFunc {
|
||||
return withRoutes(tsaddr.ExitRoutes()...)
|
||||
}
|
||||
|
||||
func withName(name string) nodeOptFunc {
|
||||
return func(n *tailcfg.Node) {
|
||||
n.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
func withRoutes(routes ...netip.Prefix) nodeOptFunc {
|
||||
return func(n *tailcfg.Node) {
|
||||
n.AllowedIPs = append(n.AllowedIPs, routes...)
|
||||
}
|
||||
}
|
||||
|
||||
var _ routecheck.NodeBackender = &stubBackend{}
|
||||
var _ routecheck.NodeBackend = &stubBackend{}
|
||||
var _ routecheck.NetMapper = &stubBackend{}
|
||||
var _ routecheck.Pinger = &stubBackend{}
|
||||
|
||||
type stubBackend struct {
|
||||
self tailcfg.NodeView
|
||||
peers []tailcfg.NodeView
|
||||
gone set.Set[tailcfg.NodeID]
|
||||
}
|
||||
|
||||
type backendOptFunc func(*stubBackend)
|
||||
|
||||
func newStubBackend(self tailcfg.NodeView, peers []tailcfg.NodeView, opts ...backendOptFunc) *stubBackend {
|
||||
b := &stubBackend{
|
||||
self: self,
|
||||
peers: slices.Clone(peers),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *stubBackend) NetMapNoPeers() *netmap.NetworkMap {
|
||||
if !b.self.Valid() {
|
||||
return nil
|
||||
}
|
||||
return &netmap.NetworkMap{
|
||||
SelfNode: b.self,
|
||||
Peers: nil, // No peers.
|
||||
}
|
||||
}
|
||||
|
||||
func (b *stubBackend) NetMapWithPeers() *netmap.NetworkMap {
|
||||
nm := b.NetMapNoPeers()
|
||||
if nm != nil {
|
||||
nm.Peers = b.peers
|
||||
}
|
||||
return nm
|
||||
}
|
||||
|
||||
func (nb *stubBackend) NodeBackend() routecheck.NodeBackend {
|
||||
return nb
|
||||
}
|
||||
|
||||
func (nb *stubBackend) Self() tailcfg.NodeView {
|
||||
return nb.self
|
||||
}
|
||||
|
||||
func (nb *stubBackend) Peers() []tailcfg.NodeView {
|
||||
return nb.peers
|
||||
}
|
||||
|
||||
func (b *stubBackend) Ping(ip netip.Addr, pingType tailcfg.PingType, size int, cb func(*ipnstate.PingResult)) {
|
||||
// Does the IP address match one of the peers’ addresses?
|
||||
for _, n := range b.peers {
|
||||
for _, a := range n.Addresses().All() {
|
||||
if a.Addr() != ip {
|
||||
continue
|
||||
}
|
||||
|
||||
if b.gone.Contains(n.ID()) {
|
||||
continue
|
||||
}
|
||||
|
||||
go cb(&ipnstate.PingResult{
|
||||
IP: ip.String(),
|
||||
NodeIP: ip.String(),
|
||||
NodeName: n.Name(),
|
||||
LatencySeconds: 0.01,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withGone(gone ...tailcfg.NodeID) backendOptFunc {
|
||||
return func(b *stubBackend) {
|
||||
b.gone = set.SetOf(gone)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 {
|
||||
var routers RoutersByPrefix
|
||||
for _, n := range c.nb.NodeBackend().Peers() {
|
||||
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
|
||||
AllowedIPs:
|
||||
for _, pfx := range n.AllowedIPs().All() {
|
||||
// Routers never forward their own local addresses.
|
||||
if views.SliceContains(n.Addresses(), pfx) {
|
||||
continue AllowedIPs
|
||||
}
|
||||
routes = append(routes, pfx)
|
||||
}
|
||||
return routes
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tsconst
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// DefaultPingTimeout is the default time we wait for a pong reply
|
||||
// before assuming it's never coming.
|
||||
DefaultPingTimeout = 5 * time.Second
|
||||
|
||||
// DefaultPingInterval is the default minimum time
|
||||
// between pings to an endpoint.
|
||||
DefaultPingInterval = 5 * time.Second
|
||||
)
|
||||
@@ -51,6 +51,7 @@ import (
|
||||
"tailscale.com/net/tstun"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsconst"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/tstime/mono"
|
||||
"tailscale.com/types/key"
|
||||
@@ -3995,18 +3996,18 @@ const (
|
||||
var (
|
||||
// pingTimeoutDuration is how long we wait for a pong reply before
|
||||
// assuming it's never coming.
|
||||
pingTimeoutDuration = 5 * time.Second
|
||||
pingTimeoutDuration = tsconst.DefaultPingTimeout
|
||||
|
||||
// discoPingInterval is the minimum time between pings
|
||||
// to an endpoint. (Except in the case of CallMeMaybe frames
|
||||
// resetting the counter, as the first pings likely didn't through
|
||||
// the firewall)
|
||||
discoPingInterval = 5 * time.Second
|
||||
discoPingInterval = tsconst.DefaultPingInterval
|
||||
|
||||
// wireguardPingInterval is the minimum time between pings to an endpoint.
|
||||
// Pings are only sent if we have not observed bidirectional traffic with an
|
||||
// endpoint in at least this duration.
|
||||
wireguardPingInterval = 5 * time.Second
|
||||
wireguardPingInterval = tsconst.DefaultPingInterval
|
||||
)
|
||||
|
||||
// indexSentinelDeleted is the temporary value that endpointState.index takes while
|
||||
|
||||
Reference in New Issue
Block a user