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>
228 lines
6.4 KiB
Go
228 lines
6.4 KiB
Go
// 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 watcher’s 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 don’t affect this node’s 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")
|
||
}
|