diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index def3fc147..7dd6155fb 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -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 diff --git a/feature/routecheck/ipn.go b/feature/routecheck/ipn.go new file mode 100644 index 000000000..6daf06fd7 --- /dev/null +++ b/feature/routecheck/ipn.go @@ -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() +} diff --git a/feature/routecheck/routecheck.go b/feature/routecheck/routecheck.go index 36ff898e3..aac603a11 100644 --- a/feature/routecheck/routecheck.go +++ b/feature/routecheck/routecheck.go @@ -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 } diff --git a/ipn/ipnext/ipnext.go b/ipn/ipnext/ipnext.go index 5ca50498a..b620d8609 100644 --- a/ipn/ipnext/ipnext.go +++ b/ipn/ipnext/ipnext.go @@ -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 diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 8e8b25f1c..6178fabc4 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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() diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index 75550b3d5..b8177f70e 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -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() diff --git a/ipn/routecheck/log.go b/ipn/routecheck/log.go new file mode 100644 index 000000000..0a92cefe9 --- /dev/null +++ b/ipn/routecheck/log.go @@ -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...) + } +} diff --git a/ipn/routecheck/probe.go b/ipn/routecheck/probe.go new file mode 100644 index 000000000..cf6289330 --- /dev/null +++ b/ipn/routecheck/probe.go @@ -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() + } + +} diff --git a/ipn/routecheck/report.go b/ipn/routecheck/report.go new file mode 100644 index 000000000..a3a48d70b --- /dev/null +++ b/ipn/routecheck/report.go @@ -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 +} diff --git a/ipn/routecheck/routecheck.go b/ipn/routecheck/routecheck.go new file mode 100644 index 000000000..a0924efef --- /dev/null +++ b/ipn/routecheck/routecheck.go @@ -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 +} diff --git a/ipn/routecheck/routecheck_test.go b/ipn/routecheck/routecheck_test.go new file mode 100644 index 000000000..39ae7f4c3 --- /dev/null +++ b/ipn/routecheck/routecheck_test.go @@ -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) + } + +} diff --git a/ipn/routecheck/routes.go b/ipn/routecheck/routes.go new file mode 100644 index 000000000..2a047cc87 --- /dev/null +++ b/ipn/routecheck/routes.go @@ -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 +}