feature/routecheck,ipn/routecheck: probe reachability in the background

Previously, refreshing the routecheck.Client would probe to generate a
new routecheck.Report, but this method was only wired up to the
LocalAPI and the `tailscale routecheck` command. However, waiting for
a probe to finish before choosing a router would take too long, so we
must keep a regularly updated report to be consulted as necessary.

This patch adds a Start and Close method to the routecheck.Client and
starts it in the background from features/routecheck. To enable this
feature for a given node, set both of the following node attributes:
`client-side-reachability` and `client-side-reachability-routecheck`.

This patch also wires up the RouterTracker.OnRoutersChange hook, which
fires a callback whenever a new network map includes information about
a router node, This signals to the routecheck.Client that it might
need to schedule another probe, if the shape of the routing table has
changed materially.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
Simon Law
2026-07-02 20:26:27 -07:00
committed by Simon Law
parent d8ee47d1cf
commit cb7e536804
6 changed files with 261 additions and 44 deletions
+15 -10
View File
@@ -37,18 +37,16 @@ func serveRouteCheck(h *localapi.Handler, w http.ResponseWriter, r *http.Request
return
}
var err error
var report *routecheck.Report
report := rc.Report()
if def.Bool(r.FormValue("probe"), false) {
timeout := def.Duration(r.FormValue("timeout"), routecheck.DefaultTimeout)
timeout = min(max(0, timeout), 60*time.Second) // clamp to [0s, 60s]
report, err = rc.Refresh(r.Context(), timeout)
} else {
report = rc.Report()
}
if err != nil {
localapi.WriteErrorJSON(w, err)
return
timeout = clampRouteCheckTimeout(timeout)
rp, err := rc.Refresh(r.Context(), timeout)
if err != nil {
localapi.WriteErrorJSON(w, err)
return
}
report = rp
}
w.Header().Set("Content-Type", "application/json")
@@ -61,3 +59,10 @@ func serveRouteCheck(h *localapi.Handler, w http.ResponseWriter, r *http.Request
// with its default options, marshal with DefaultOptionsV1.
jsonv2.MarshalWrite(w, report, jsonv1.DefaultOptionsV1())
}
func clampRouteCheckTimeout(timeout time.Duration) time.Duration {
if timeout < 0 {
timeout = routecheck.DefaultTimeout
}
return min(max(0, timeout), 60*time.Second) // clamp to [0s, 60s]
}
+13 -14
View File
@@ -22,6 +22,7 @@ import (
"tailscale.com/net/routecheck"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
)
// FeatureName is the name of the feature implemented by this package.
@@ -43,6 +44,7 @@ type Extension struct {
logf logger.Logf
backend ipnext.SafeBackend
ec *eventbus.Client
nb nodeBackender
nm routecheck.NetMapper
routers *RouterTracker
@@ -83,7 +85,7 @@ func (e *Extension) Init(h ipnext.Host) error {
pinger := e.backend.Sys().Engine.Get()
c, err := routecheck.NewClient(e.logf, e.nb, e.nm, pinger)
c, err := routecheck.NewClient(context.Background(), e.logf, e.nb, e.nm, pinger)
if err != nil {
return err
}
@@ -91,7 +93,11 @@ func (e *Extension) Init(h ipnext.Host) error {
e.routers = TrackRouters(context.Background(), e.logf, ipnbus)
e.routers.OnNetMapAvailable = e.Client.NotifyNetMapAvailable
e.routers.OnRoutersChange = e.incrementalRefresh
e.routers.OnRoutersChange = e.Client.NeedsIncrRefresh
bus := e.backend.Sys().Bus.Get()
e.ec = bus.Client("routecheck")
eventbus.SubscribeFunc(e.ec, e.Client.WatchForNetMonRebind)
// Watch for changes to the self node that would toggle the routecheck feature.
e.reconcile.args = make(chan tailcfg.NodeView, 1)
@@ -99,6 +105,9 @@ func (e *Extension) Init(h ipnext.Host) error {
go e.reconcileLoop()
h.Hooks().OnSelfChange.Add(e.reconcileWatcher)
// Probe for reachable peers.
go e.Client.Start()
return nil
}
@@ -109,22 +118,12 @@ func (e *Extension) Shutdown() error {
close(e.reconcile.args) // lock prevents reconcileWatcher from writing to this channel
e.reconcile.Unlock()
e.ec.Close()
e.routers.Close() // stop the watcher before waiting for reconcile.done
<-e.reconcile.done
return e.Client.Close()
}
func (e *Extension) needsRefresh() {
// TODO(sfllaw): Call e.Client.NeedsRefresh() after implementing it.
}
func (e *Extension) incrementalRefresh(added, modified, removed []tailcfg.NodeID) {
// TODO(sfllaw): This refresh should be incremental,
// based on the added, modified, and removed nodes.
// Currently it refreshes everything.
e.needsRefresh()
}
// reconcileWatcher is called whenever e.routers should start, stop, or restart its watcher.
// It may trigger a restart when self indicates that we have switched to a different tailnet or user,
// in order to reset the internal state of e.routers and start tracking from scratch.
@@ -159,7 +158,7 @@ func (e *Extension) reconcileLoop() {
continue // can be started by toggling the nodeattr
}
if started {
e.needsRefresh()
e.Client.NeedsRefresh()
}
}
}