WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
14 changed files with 1206 additions and 6 deletions
Showing only changes of commit 28801674a6 - Show all commits
+2 -1
View File
@@ -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
+18
View File
@@ -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()
}
+72 -1
View File
@@ -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)
}
+13
View File
@@ -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
+8 -1
View File
@@ -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)
+1
View File
@@ -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()
+32
View 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...)
}
}
+276
View File
@@ -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 dont want to probe nodes that dont understand Disco pings.
//
// We could establish a WireGuard session to probe them,
// which would allow us to exclude nodes that wont 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 others 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 doesnt 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 nodes IPv4 address is preferred, if the current node also supports IPv4.
// A nodes 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 doesnt 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 doesnt 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 theres 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
}
}
+61
View File
@@ -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 isnt available, which happens during initialization.
func (c *Client) Report() *Report {
metricReport.Add(1)
nm := c.nm.NetMapNoPeers()
if nm == nil {
return nil // The report wasnt 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
}
+165
View File
@@ -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
}
+487
View File
@@ -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)
}
}
+51
View File
@@ -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 nodes 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 arent 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 nodes 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
}
+16
View File
@@ -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
)
+4 -3
View File
@@ -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