mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-07 21:26:41 +02:00
ipn/routecheck: introduce new package for checking peer reachability
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 only 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 PR 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. Updates #17366 Updates tailscale/corp#33033 Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
parent
d5955a102f
commit
674d438282
@ -328,6 +328,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
|
||||
tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver+
|
||||
tailscale.com/ipn/policy from tailscale.com/feature/portlist
|
||||
tailscale.com/ipn/routecheck from tailscale.com/feature/routecheck
|
||||
tailscale.com/ipn/store from tailscale.com/cmd/tailscaled+
|
||||
L tailscale.com/ipn/store/awsstore from tailscale.com/feature/condregister
|
||||
L tailscale.com/ipn/store/kubestore from tailscale.com/feature/condregister
|
||||
|
||||
20
feature/routecheck/ipn.go
Normal file
20
feature/routecheck/ipn.go
Normal file
@ -0,0 +1,20 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_routecheck
|
||||
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/ipn/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()
|
||||
}
|
||||
@ -7,5 +7,63 @@
|
||||
// which checks the reachability of overlapping routers.
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/ipn/routecheck"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// 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() {
|
||||
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
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
pinger := e.backend.Sys().Engine.Get()
|
||||
nm, ok := e.backend.(routecheck.NetMapWaiter)
|
||||
if !ok {
|
||||
return fmt.Errorf("backend %T does not implement routecheck.NetMapWaiter", e.backend)
|
||||
}
|
||||
|
||||
c, err := routecheck.NewClient(e.logf, e.nb, nm, pinger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Client = c
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown implements the [ipnext.Extension.Shutdown] interface method.
|
||||
func (e *Extension) Shutdown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -465,10 +465,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
|
||||
|
||||
|
||||
@ -4990,6 +4990,25 @@ func (b *LocalBackend) NetMap() *netmap.NetworkMap {
|
||||
return b.currentNode().NetMap()
|
||||
}
|
||||
|
||||
// WaitForNetMap returns the latest cached network map received from
|
||||
// controlclient, or waits until the initial network map has been received.
|
||||
func (b *LocalBackend) WaitForNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
|
||||
nm := b.NetMap()
|
||||
if nm != nil {
|
||||
return nm, nil
|
||||
}
|
||||
|
||||
// Wait for the initial NetworkMap to arrive:
|
||||
b.WatchNotifications(ctx, ipn.NotifyInitialNetMap, nil, func(n *ipn.Notify) (keepGoing bool) {
|
||||
nm = n.NetMap
|
||||
return nm == nil // Keep going until nm contains a network map.
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nm, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) isEngineBlocked() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
@ -132,6 +132,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()
|
||||
|
||||
32
ipn/routecheck/log.go
Normal file
32
ipn/routecheck/log.go
Normal file
@ -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...)
|
||||
}
|
||||
}
|
||||
190
ipn/routecheck/probe.go
Normal file
190
ipn/routecheck/probe.go
Normal file
@ -0,0 +1,190 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"iter"
|
||||
"math/rand/v2"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
// DefaultTimeout is the default time allowed for a response before a peer is considered unreachable.
|
||||
const DefaultTimeout = 4 * time.Second
|
||||
|
||||
type probed struct {
|
||||
id tailcfg.NodeID
|
||||
name string
|
||||
addr netip.Addr
|
||||
routes []netip.Prefix
|
||||
}
|
||||
|
||||
func (c *Client) probe(ctx context.Context, nodes iter.Seq[probed], limit int, timeout time.Duration) (*Report, error) {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
if limit > 0 {
|
||||
g.SetLimit(limit)
|
||||
}
|
||||
|
||||
var mu syncs.Mutex
|
||||
r := &Report{}
|
||||
for n := range nodes {
|
||||
g.Go(func() error {
|
||||
pong, err := c.ping(ctx, n.addr, timeout)
|
||||
if err != nil {
|
||||
// Returning an error would cancel the errgroup.
|
||||
c.vlogf("ping %s (%s): error: %v", n.addr, n.id, err)
|
||||
return nil
|
||||
} else if pong == nil {
|
||||
c.vlogf("ping %s (%s): error: no response", n.addr, n.id)
|
||||
return nil
|
||||
} else {
|
||||
c.vlogf("ping %s (%s): result: %f ms (err: %v)", n.addr, n.id, pong.LatencySeconds*1000, pong.Err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if _, ok := r.Reachable[n.id]; !ok {
|
||||
mak.Set(&r.Reachable, n.id, Node{
|
||||
ID: n.id,
|
||||
Name: n.name,
|
||||
Addr: n.addr,
|
||||
Routes: n.routes,
|
||||
})
|
||||
}
|
||||
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 tries both the IPv4 and IPv6 addresses
|
||||
func (c *Client) Probe(ctx context.Context, nodes iter.Seq[tailcfg.NodeView], limit int, timeout time.Duration) (*Report, error) {
|
||||
var canIPv4, canIPv6 bool
|
||||
for _, ip := range c.nb.NodeBackend().Self().Addresses().All() {
|
||||
addr := ip.Addr()
|
||||
if addr.Is4() {
|
||||
canIPv4 = true
|
||||
} else if addr.Is6() {
|
||||
canIPv6 = true
|
||||
}
|
||||
}
|
||||
|
||||
var dsts iter.Seq[probed] = func(yield func(probed) bool) {
|
||||
for n := range nodes {
|
||||
// Ping one of the tailnet addresses.
|
||||
for _, ip := range n.Addresses().All() {
|
||||
// Skip this probe if there is an IP version mismatch.
|
||||
addr := ip.Addr()
|
||||
if addr.Is4() && !canIPv4 {
|
||||
continue
|
||||
}
|
||||
if addr.Is6() && !canIPv6 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !yield(probed{
|
||||
id: n.ID(),
|
||||
name: n.Name(),
|
||||
addr: addr,
|
||||
routes: routes(n),
|
||||
}) {
|
||||
return
|
||||
}
|
||||
break // We only need one address for every node.
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.probe(ctx, dsts, limit, timeout)
|
||||
}
|
||||
|
||||
// ProbeAllPeers actively probes all peers in parallel and returns a [Report]
|
||||
// that identifies which nodes 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) ProbeAllPeers(ctx context.Context, limit int, timeout time.Duration) (*Report, error) {
|
||||
nm, err := c.nm.WaitForNetMap(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Probe(ctx, slices.Values(nm.Peers), 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.nm.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()
|
||||
})
|
||||
|
||||
// To prevent swarming, each node should probe in a different order.
|
||||
seed := uint64(nm.SelfNode.ID())
|
||||
rnd := rand.New(rand.NewPCG(seed, seed))
|
||||
rnd.Shuffle(len(nodes), func(i, j int) {
|
||||
nodes[i], nodes[j] = nodes[j], nodes[i]
|
||||
})
|
||||
|
||||
return c.Probe(ctx, slices.Values(nodes), limit, timeout)
|
||||
}
|
||||
|
||||
// Ping returns the result of a TSMP 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, timeout time.Duration) (*ipnstate.PingResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan *ipnstate.PingResult, 1)
|
||||
c.pinger.Ping(ip, tailcfg.PingTSMP, 0, func(pr *ipnstate.PingResult) {
|
||||
select {
|
||||
case ch <- pr:
|
||||
default:
|
||||
}
|
||||
})
|
||||
select {
|
||||
case pr := <-ch:
|
||||
return pr, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
}
|
||||
55
ipn/routecheck/report.go
Normal file
55
ipn/routecheck/report.go
Normal file
@ -0,0 +1,55 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Report returns the latest reachability report.
|
||||
// Returns nil if a report isn’t available, which happens during initialization.
|
||||
func (c *Client) Report() *Report {
|
||||
nm := c.nm.NetMap()
|
||||
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
|
||||
}
|
||||
83
ipn/routecheck/routecheck.go
Normal file
83
ipn/routecheck/routecheck.go
Normal file
@ -0,0 +1,83 @@
|
||||
// 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"
|
||||
|
||||
"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 NetMapWaiter
|
||||
pinger Pinger
|
||||
}
|
||||
|
||||
// NetMapWaiter is the interface that returns the current [netmap.NetworkMap].
|
||||
type NetMapWaiter interface {
|
||||
// NetMap returns the latest cached network map received from controlclient,
|
||||
// or nil if no network map was received yet.
|
||||
NetMap() *netmap.NetworkMap
|
||||
|
||||
// WaitForNetMap returns the latest cached network map received from controlclient,
|
||||
// or waits for until the initial network map has been received.
|
||||
WaitForNetMap(context.Context) (*netmap.NetworkMap, error)
|
||||
}
|
||||
|
||||
// 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 [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 NetMapWaiter, pinger Pinger) (*Client, error) {
|
||||
if nb == nil {
|
||||
return nil, errors.New("NodeBackender must be set")
|
||||
}
|
||||
if nm == nil {
|
||||
return nil, errors.New("NetMapWaiter must be set")
|
||||
}
|
||||
if pinger == nil {
|
||||
return nil, errors.New("Pinger must be set")
|
||||
}
|
||||
return &Client{
|
||||
Logf: logf,
|
||||
nb: nb,
|
||||
nm: nm,
|
||||
pinger: pinger,
|
||||
}, nil
|
||||
}
|
||||
493
ipn/routecheck/routecheck_test.go
Normal file
493
ipn/routecheck/routecheck_test.go
Normal file
@ -0,0 +1,493 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package routecheck_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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/ipn/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 _, tc := 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 tc.peers {
|
||||
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 interface{}) string {
|
||||
return gcmp.Diff(want, got,
|
||||
gcmpopts.EquateComparable(netip.Addr{}, netip.Prefix{}))
|
||||
}
|
||||
|
||||
t.Run(tc.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(tc.gone...))
|
||||
if !tc.init {
|
||||
self := makeNode(99, withName("self"))
|
||||
b = newStubBackend(self, tc.peers, withGone(tc.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(tc.peers)
|
||||
if !tc.init {
|
||||
want = &routecheck.Report{
|
||||
Done: now,
|
||||
}
|
||||
for _, nid := range tc.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 _, tc := 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(tc.name, func(t *testing.T) {
|
||||
self := makeNode(99, withName("self"))
|
||||
b := newStubBackend(self, tc.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, tc.want, slices.Equal) {
|
||||
t.Errorf("got %+v, want %+v", got, tc.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.NetMapWaiter = &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) NetMap() *netmap.NetworkMap {
|
||||
if !b.self.Valid() {
|
||||
return nil
|
||||
}
|
||||
return &netmap.NetworkMap{
|
||||
SelfNode: b.self,
|
||||
Peers: b.peers,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *stubBackend) WaitForNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
|
||||
nm := b.NetMap()
|
||||
if nm == nil {
|
||||
<-ctx.Done()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("no netmap to wait for")
|
||||
}
|
||||
return nm, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
46
ipn/routecheck/routes.go
Normal file
46
ipn/routecheck/routes.go
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package routecheck
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
// RoutersByPrefix represents a map of nodes grouped by the subnet that they route.
|
||||
type RoutersByPrefix map[netip.Prefix][]tailcfg.NodeView
|
||||
|
||||
// RoutersByPrefix returns 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.
|
||||
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.
|
||||
for _, addr := range n.Addresses().All() {
|
||||
if pfx == addr {
|
||||
continue AllowedIPs
|
||||
}
|
||||
}
|
||||
routes = append(routes, pfx)
|
||||
}
|
||||
return routes
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user