Files
tailscale/feature/routecheck/routertracker.go
T
d8ee47d1cf ipn/ipnext, feature/routecheck: subscribe routecheck to the IPN bus
This patch adds a new ipnext.NotifyWatcher interface that exposes
ipn.LocalBackend.WatchNotifications so that extensions inside
tailscaled can subscribe to the IPN bus, much like how the GUI
clients subscribe to it through the Local API.

This interface is used by the new feature/routecheck.RouterTracker to
watch for changes in the peer map that affect routers. RouterTracker
uses dead reckoning to incrementally maintain the set of routers. We
do this to avoid looping over the peer map repeatedly. See #17366.

RouterTracker supports two hooks:

- OnNetMapAvailable signals that the initial netmap has been received,
  so that the routecheck.Client can wake up goroutines that are
  waiting for it.

- OnRoutersChange signals that the set of routers has changed, so that
  the routecheck.Client can decide to probe a subset of the routers
  instead of all of them. Currently, this optimization hasn’t been
  implemented yet.

Updates #17366
Updates #20062
Updates tailscale/corp#33033

Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00

228 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routecheck
import (
"context"
"errors"
"fmt"
"sync"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/net/routecheck"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/set"
)
var ErrRouteCheckNotEnabled = errors.New("routecheck not enabled")
type RouterTracker struct {
// OnNetMapAvailable is called when the initial network map is received
// or is loaded from its cache.
OnNetMapAvailable func()
// OnRoutersChange is called when one or more peer nodes, which function as routers,
// have been added, removed, or change their routes.
OnRoutersChange func(added, modified, removed []tailcfg.NodeID)
ctx context.Context // root context
logf logger.Logf
ipnbus ipnext.NotifyWatcher
mu sync.Mutex
closed bool
self tailcfg.NodeView // self node for the IPN bus being watched
cancel context.CancelFunc // non-nil iff the watcher is running
done chan struct{} // closed by the watcher goroutine when it exits
}
// TrackRouters returns a tracker for keeping track of which nodes are routers
// by watching the IPN bus for netmap changes.
func TrackRouters(ctx context.Context, logf logger.Logf, ipnbus ipnext.NotifyWatcher) *RouterTracker {
return &RouterTracker{
ctx: ctx,
logf: logf,
ipnbus: ipnbus,
}
}
// Close implements the [io.Closer] interface.
func (rt *RouterTracker) Close() error {
if rt == nil {
return nil
}
rt.mu.Lock()
defer rt.mu.Unlock()
if rt.closed {
return nil
}
rt.closed = true
rt.stopWatcherLocked()
return nil
}
// StartStopWatcher starts or stops watching the IPN bus based on
// the state of the "client-side-reachability-routecheck" node attribute:
// see [tailcfg.NodeAttrClientSideReachabilityRouteCheck].
//
// StartStopWatcher considers stopping and then restarting the watcher goroutine
// if the self node and user differ from the ones that started the current watcher.
// It stops or starts the watcher when routecheck is disabled or enabled, respectively.
//
// StartStopWatcher reports whether the watcher goroutine was started,
// either because it was previously stopped or because it needed restarting.
func (rt *RouterTracker) StartStopWatcher(self tailcfg.NodeView) (started bool, _ error) {
rt.mu.Lock()
defer rt.mu.Unlock()
orig := rt.self
rt.self = self
toggled := routecheck.IsEnabled(orig) != routecheck.IsEnabled(self)
if toggled || !sameNode(orig, self) {
rt.stopWatcherLocked()
if err := rt.startWatcherLocked(self); err != nil {
return false, err
}
return true, nil
}
return false, nil
}
// sameNode reports whether a and b have the same [tailcfg.NodeView.ID]s.
func sameNode(a, b tailcfg.NodeView) bool {
var aID, bID tailcfg.NodeID
if a.Valid() {
aID = a.ID()
}
if b.Valid() {
bID = b.ID()
}
return aID == bID
}
// startWatcherLocked launches the goroutine that watches the IPN bus.
// rt.mu must be held and the watcher must not already be running.
func (rt *RouterTracker) startWatcherLocked(self tailcfg.NodeView) error {
syncs.RequiresMutex(&rt.mu)
if rt.closed {
return fmt.Errorf("cannot start, tracker was closed")
}
if rt.cancel != nil || rt.done != nil {
return fmt.Errorf("cannot start, already watching IPN bus")
}
if !routecheck.IsEnabled(self) {
if !self.Valid() {
return ErrRouteCheckNotEnabled
}
return fmt.Errorf("%w for %v on %v", ErrRouteCheckNotEnabled, self.User(), self.ID())
}
rt.self = self
ctx, cancel := context.WithCancel(rt.ctx)
rt.cancel = cancel
rt.done = make(chan struct{})
go rt.watchIPNBus(ctx, rt.done, self)
return nil
}
// stopWatcherLocked cancels the watcher goroutine and waits for it to exit.
// rt.mu must be held. It is a no-op when no watcher is running.
//
// Blocking while locked is safe because the watchers callback never locks rt.mu.
func (rt *RouterTracker) stopWatcherLocked() {
syncs.RequiresMutex(&rt.mu)
var stopped bool
if rt.cancel != nil {
rt.cancel()
rt.cancel = nil
stopped = true
}
if rt.done != nil {
<-rt.done
rt.done = nil
stopped = true
}
rt.self = tailcfg.NodeView{}
if stopped {
rt.logf("stopped tracking routers")
}
}
// watchIPNBus subscribes to the IPN bus to learn about changes to the peer map,
// so that it can keep track of which nodes are routers by dead-reckoning.
// The set of routers is tracked internally to process peer churn without locking.
//
// When routers are added, removed, or change their routes,
// it fires the [RouterTracker.OnRoutersChange] hook.
// See tailscale/tailscale#12542.
//
// When the client gets the initial netmap after connecting to the control plane,
// it fires the [RouterTracker.OnNetMapAvailable] hook.
//
// To avoid stalls, these notifications must be processed promptly
// because we enabled [ipn.NotifyInProcessNoDisconnect] which blocks the caller.
func (rt *RouterTracker) watchIPNBus(ctx context.Context, done chan<- struct{}, self tailcfg.NodeView) {
defer close(done)
routers := make(set.Set[tailcfg.NodeID])
const mask = ipn.NotifyInProcessNoDisconnect | ipn.NotifyInitialStatus | ipn.NotifyPeerChanges
rt.ipnbus.WatchNotifications(ctx, mask, nil, func(n *ipn.Notify) bool {
var added, modified, removed []tailcfg.NodeID
if s := n.InitialStatus; s != nil {
if rt.OnNetMapAvailable != nil {
rt.OnNetMapAvailable()
}
// Bootstrap the router set from the initial Status.
// This will trigger the initial probe for all routers.
for _, ps := range s.Peer {
if ps.IsRouter() {
nid := ps.NodeID
routers.Add(nid)
added = append(added, nid)
}
}
}
for _, p := range n.PeersChanged {
nid := p.ID
wasRouter := routers.Contains(p.ID)
isRouter := p.IsRouter()
switch {
case !wasRouter && isRouter:
routers.Add(nid)
added = append(added, nid)
case wasRouter && isRouter:
// TODO(sfllaw): Tune this to ignore changes
// that dont affect this nodes status as a router.
modified = append(modified, nid)
case wasRouter && !isRouter:
routers.Delete(nid)
removed = append(removed, nid)
}
}
for _, nid := range n.PeersRemoved {
if routers.Contains(nid) {
routers.Delete(nid)
removed = append(removed, nid)
}
}
if added != nil || modified != nil || removed != nil {
if rt.OnRoutersChange != nil {
rt.OnRoutersChange(added, modified, removed)
}
}
return true
})
rt.logf("stopped tracking routers")
}