feature/exitnodehealth: refine exit node warnings based on responsiveness

Report when an active selected exit node has not answered disco messages for a minute. Include the node name and last response time in the warning.

Evaluate responsiveness from existing magicsock heartbeats and clear the warning when the peer becomes idle or responds. Use changes to nonzero WireGuard RX and TX counters to suppress warnings when traffic is working. Keep observation history and warning evaluation in the exitnodehealth feature. WireGuard-only peers are excluded.

Updates #tailscale/corp#33007

Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
James Tucker authored and James Tucker committed 2026-10-07 15:38:22 -07:00
1 parent 9c00fd0330
commit 852c3d1f0a
9 files changed
+533 -7

No files matched your search

+19 -7
View File
@@ -4,7 +4,7 @@
// Package exitnodehealth reports unusable exit node configurations via
// health warnables.
//
// It does not infer or probe data-plane reachability.
// Health state is refined using traffic and heartbeat signals from magicsock.
package exitnodehealth
import (
@@ -24,6 +24,7 @@ import (
"tailscale.com/types/logger"
"tailscale.com/util/syspolicy/pkey"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/wgengine/magicsock"
)
const featureName = "exitnodehealth"
@@ -32,6 +33,8 @@ func init() {
if !feature.Register(featureName) {
return
}
magicsock.HookExitNodeHeartbeatStateLocked.Set(reportExitNodeHeartbeatStateLocked)
magicsock.HookExitNodeCheckResponsiveness.Set(checkExitNodeResponsiveness)
ipnext.RegisterExtension(featureName, newExtension)
}
@@ -39,16 +42,18 @@ func newExtension(logf logger.Logf, b ipnext.SafeBackend) (ipnext.Extension, err
if !buildfeatures.HasHealth || !buildfeatures.HasUseExitNode {
return nil, ipnext.SkipExtension
}
return &extension{logf: logf, health: b.Sys().HealthTracker.Get(), polc: b.Sys().PolicyClientOrDefault()}, nil
ms, _ := b.Sys().MagicSock.GetOK()
return &extension{logf: logf, health: b.Sys().HealthTracker.Get(), polc: b.Sys().PolicyClientOrDefault(), magicsock: ms}, nil
}
// extension owns the health state for one backend.
type extension struct {
host ipnext.Host
logf logger.Logf
health *health.Tracker
polc policyclient.Client
logf logger.Logf
health *health.Tracker
polc policyclient.Client
magicsock *magicsock.Conn
// mu protects the fields below.
//
@@ -60,6 +65,7 @@ type extension struct {
closed bool
reason ExitNodeHealthVerdict // last reported reason, for transition logs
lastID tailcfg.StableNodeID // last evaluated selection, independent of name caching
responsiveness responsiveness
// Remember a peer's name and/or ID so warnings can still identify it after removal.
// It may prove useful to persist this across sessions, but for now we only remember it while the backend is running.
@@ -84,6 +90,10 @@ func (e *extension) Shutdown() error {
e.mu.Lock()
defer e.mu.Unlock()
e.closed = true
if e.magicsock != nil {
activityObservers.Delete(e.magicsock)
}
e.health.SetHealthy(exitNodeUnresponsiveWarnable)
return nil
}
@@ -133,12 +143,14 @@ func (e *extension) updateLocked() {
prefs := e.host.Profiles().CurrentPrefs()
node := e.host.NodeBackend()
peer, _ := node.PeerByStableID(prefs.ExitNodeID())
e.updateWarnableLocked(healthContext{
c := healthContext{
State: e.state,
NetworkConfigured: e.networkConfigured,
Prefs: prefs,
Peer: peer,
})
}
e.updateWarnableLocked(c)
e.updateResponsivenessSelectionLocked(c)
}
// ExitNodeHealthVerdict describes why the selected exit node cannot carry
+191
View File
@@ -0,0 +1,191 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package exitnodehealth
import (
"fmt"
"sync"
"time"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/tsconst"
"tailscale.com/tstime/mono"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
"tailscale.com/wgengine/wgint"
)
const exitNodeResponseTimeout = time.Minute
// ArgLastResponseTime is the UTC RFC3339 time of the last response, or the
// start of monitoring if no response has been received.
const ArgLastResponseTime health.Arg = "last-response-time"
var exitNodeUnresponsiveWarnable = health.Register(&health.Warnable{
Code: tsconst.HealthWarnableExitNodeUnresponsive,
Title: "Exit node unresponsive",
Severity: health.SeverityHigh,
DependsOn: []*health.Warnable{health.IPNStateWarnable, health.NetworkStatusWarnable},
ImpactsConnectivity: true,
Text: func(args health.Args) string {
return fmt.Sprintf("The selected exit node %q has not responded since %s. Internet connectivity may be affected.", args[ArgExitNodeName], args[ArgLastResponseTime])
},
})
// Only the feature retains history, for the selected exit node alone.
type responsiveness struct {
key key.NodePublic
name string
started, checked, lastResponse mono.Time
idle bool
window time.Duration
txBytes, rxBytes uint64
lastTx, lastRx mono.Time
}
var activityObservers sync.Map // *magicsock.Conn -> *extension
func observerFor(c *magicsock.Conn) *extension {
if v, ok := activityObservers.Load(c); ok {
return v.(*extension)
}
return nil
}
func reportExitNodeHeartbeatStateLocked(c *magicsock.Conn, k key.NodePublic, now mono.Time, idle, pong bool, window time.Duration) {
if e := observerFor(c); e != nil {
e.recordActivity(k, now, idle, pong, window)
}
}
// Counter lookup occurs only in the outside-lock hook. The inside-lock hook
// records state but never publishes an unresponsive transition before this
// filter has had a chance to check working traffic.
func checkExitNodeResponsiveness(c *magicsock.Conn, k key.NodePublic, getPeer func(key.NodePublic) (wgint.Peer, bool)) {
e := observerFor(c)
if e == nil {
return
}
e.mu.Lock()
s := &e.responsiveness
if e.closed || s.key.IsZero() || s.key != k || s.idle {
e.mu.Unlock()
return
}
checked := s.checked
e.mu.Unlock()
var sampled bool
var tx, rx uint64
if getPeer != nil {
if p, ok := getPeer(k); ok && p.IsValid() {
sampled = true
tx, rx = p.TxBytes(), p.RxBytes()
}
}
e.mu.Lock()
defer e.mu.Unlock()
s = &e.responsiveness
if e.closed || s.key != k || s.idle || s.checked != checked {
return
}
if sampled {
e.observeTraffic(tx, rx, checked)
}
e.evaluateResponsiveness(checked)
}
func (e *extension) updateResponsivenessSelectionLocked(c healthContext) {
var k key.NodePublic
name := ""
if c.NetworkConfigured && c.Prefs.Valid() && c.Prefs.WantRunning() &&
(c.State == ipn.Running || c.State == ipn.Starting) &&
c.Peer.Valid() && !c.Peer.IsWireGuardOnly() && !c.Peer.DiscoKey().IsZero() {
if reason, n := evaluateExitNodeStatus(c); reason == ExitNodeOK {
k, name = c.Peer.Key(), n
}
}
if k != e.responsiveness.key {
e.responsiveness = responsiveness{key: k, started: mono.Now(), idle: true}
e.health.SetHealthy(exitNodeUnresponsiveWarnable)
}
e.responsiveness.name = name
if e.magicsock != nil {
if k.IsZero() {
activityObservers.Delete(e.magicsock)
} else {
activityObservers.Store(e.magicsock, e)
}
}
}
func (e *extension) recordActivity(k key.NodePublic, now mono.Time, idle, pong bool, window time.Duration) {
e.mu.Lock()
defer e.mu.Unlock()
s := &e.responsiveness
if e.closed || s.key.IsZero() || s.key != k {
return
}
if pong {
if s.lastResponse.IsZero() || now.After(s.lastResponse) {
s.lastResponse = now
}
e.health.SetHealthy(exitNodeUnresponsiveWarnable)
return
}
if !s.checked.IsZero() && now.Before(s.checked) {
return
}
if idle {
s.started = 0
e.health.SetHealthy(exitNodeUnresponsiveWarnable)
} else if s.idle || s.started.IsZero() || (!s.checked.IsZero() && now.Sub(s.checked) > window) {
// Give each active period a full response timeout, including the first
// heartbeat after selection when no idle heartbeat has been observed.
s.started = now
}
s.idle, s.checked, s.window = idle, now, window
}
// observeTraffic requires e.mu. Changes, not historical totals, provide the
// best-effort guard; unidirectional traffic does not prove a round trip.
func (e *extension) observeTraffic(tx, rx uint64, now mono.Time) {
s := &e.responsiveness
// Nonzero counter changes, including decreases, count as activity.
// A reset to zero is not evidence that packets have been exchanged.
if tx != 0 && tx != s.txBytes {
s.lastTx = now
}
if rx != 0 && rx != s.rxBytes {
s.lastRx = now
}
s.txBytes, s.rxBytes = tx, rx
if !s.lastTx.IsZero() && !s.lastRx.IsZero() && now.Sub(s.lastTx) <= s.window && now.Sub(s.lastRx) <= s.window {
if s.lastResponse.IsZero() || s.lastRx.After(s.lastResponse) {
s.lastResponse = s.lastRx
}
}
}
func (e *extension) evaluateResponsiveness(now mono.Time) {
s := &e.responsiveness
response, baseline := s.lastResponse, s.started
if !response.IsZero() && response.After(baseline) {
baseline = response
}
if now.Sub(baseline) < exitNodeResponseTimeout {
e.health.SetHealthy(exitNodeUnresponsiveWarnable)
return
}
if response.IsZero() {
response = s.started
}
name := s.name
if name == "" {
name = s.key.ShortString()
}
e.health.SetUnhealthy(exitNodeUnresponsiveWarnable, health.Args{
ArgExitNodeName: name, ArgLastResponseTime: response.WallTime().UTC().Format(time.RFC3339),
})
}
@@ -0,0 +1,187 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package exitnodehealth
import (
"testing"
"time"
"tailscale.com/ipn"
"tailscale.com/tstime/mono"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
"tailscale.com/wgengine/wgint"
)
func TestExitNodeResponsiveness(t *testing.T) {
for _, mode := range []string{"silent", "disco", "bidirectional", "counter-reset", "zero-reset", "tx-only", "rx-only", "historical"} {
t.Run(mode, func(t *testing.T) {
b := newExitNodeHealthTestBackend(t, nil)
b.ForTest().SetPrefs(&ipn.Prefs{WantRunning: true, ExitNodeID: "exit1"})
e := extOf(t, b)
start := mono.Now()
e.responsiveness.started = start
k := e.responsiveness.key
tx, rx := uint64(1000), uint64(1000)
e.responsiveness.window = 45 * time.Second
e.responsiveness.txBytes, e.responsiveness.rxBytes = tx, rx
for d := time.Duration(0); d <= 2*exitNodeResponseTimeout; d += 3 * time.Second {
now := start.Add(d)
e.recordActivity(k, now, false, false, 45*time.Second)
switch mode {
case "disco":
e.recordActivity(k, now, false, true, 45*time.Second)
case "bidirectional":
tx += 100
rx += 100
case "counter-reset":
tx--
rx--
case "zero-reset":
tx, rx = 0, 0
case "tx-only":
tx += 100
case "rx-only":
rx += 100
}
e.mu.Lock()
e.observeTraffic(tx, rx, now)
e.evaluateResponsiveness(now)
e.mu.Unlock()
want := mode != "disco" && mode != "bidirectional" && mode != "counter-reset" && d >= exitNodeResponseTimeout
if got := b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable); got != want {
t.Fatalf("at %v unhealthy=%v want=%v", d, got, want)
}
}
now := start.Add(2*exitNodeResponseTimeout + 3*time.Second)
e.recordActivity(k, now, true, false, 45*time.Second)
if b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable) {
t.Fatal("idle retained warning")
}
now = now.Add(time.Hour)
e.recordActivity(k, now, false, false, 45*time.Second)
e.mu.Lock()
e.evaluateResponsiveness(now)
e.mu.Unlock()
if b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable) {
t.Fatal("resume lacked grace")
}
})
}
}
func TestExitNodeResponsivenessActivation(t *testing.T) {
for _, observedIdle := range []bool{false, true} {
for _, responded := range []bool{false, true} {
name := "initial"
if observedIdle {
name = "resume"
}
if responded {
name += "-with-old-response"
}
t.Run(name, func(t *testing.T) {
b := newExitNodeHealthTestBackend(t, nil)
b.ForTest().SetPrefs(&ipn.Prefs{WantRunning: true, ExitNodeID: "exit1"})
e := extOf(t, b)
start := e.responsiveness.started
k := e.responsiveness.key
const window = 45 * time.Second
if observedIdle {
e.recordActivity(k, start.Add(3*time.Second), true, false, window)
}
if responded {
e.recordActivity(k, start.Add(6*time.Second), false, true, window)
}
// Selection may precede the first active heartbeat by a long time,
// without an intervening idle report. Neither that time nor an old
// response should shorten the grace period when activity begins.
active := start.Add(time.Hour)
for d := time.Duration(0); d <= exitNodeResponseTimeout; d += 3 * time.Second {
now := active.Add(d)
e.recordActivity(k, now, false, false, window)
e.mu.Lock()
started := e.responsiveness.started
e.evaluateResponsiveness(now)
e.mu.Unlock()
if started != active {
t.Fatalf("at %v started=%v want=%v", d, started, active)
}
want := d >= exitNodeResponseTimeout
if got := b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable); got != want {
t.Fatalf("at %v unhealthy=%v want=%v", d, got, want)
}
}
})
}
}
}
func TestSplitReportFiltersTransition(t *testing.T) {
b := newExitNodeHealthTestBackend(t, nil)
b.ForTest().SetPrefs(&ipn.Prefs{WantRunning: true, ExitNodeID: "exit1"})
e := extOf(t, b)
c := new(magicsock.Conn)
activityObservers.Store(c, e)
t.Cleanup(func() { activityObservers.Delete(c) })
start := mono.Now()
e.responsiveness.started = start
k := e.responsiveness.key
for d := time.Duration(0); d <= exitNodeResponseTimeout; d += 3 * time.Second {
reportExitNodeHeartbeatStateLocked(c, k, start.Add(d), false, false, 45*time.Second)
}
if b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable) {
t.Fatal("inside-lock report published before traffic filter")
}
lookups := 0
checkExitNodeResponsiveness(c, k, func(key.NodePublic) (wgint.Peer, bool) {
// Counter lookup must happen without holding the extension mutex.
e.mu.Lock()
e.mu.Unlock()
lookups++
return wgint.Peer{}, false
})
if lookups != 1 || !b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable) {
t.Fatal("outside-lock filter failed to publish unresponsive transition")
}
reportExitNodeHeartbeatStateLocked(c, k, start.Add(time.Minute+3*time.Second), true, false, 45*time.Second)
checkExitNodeResponsiveness(c, k, func(key.NodePublic) (wgint.Peer, bool) {
t.Fatal("idle looked up counters")
return wgint.Peer{}, false
})
if b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable) {
t.Fatal("idle retained warning")
}
}
func TestExitNodeResponsivenessLifecycle(t *testing.T) {
b := newExitNodeHealthTestBackend(t, nil)
b.ForTest().SetPrefs(&ipn.Prefs{WantRunning: true, ExitNodeID: "exit1"})
e := extOf(t, b)
start := mono.Now()
e.responsiveness.started = start
k := e.responsiveness.key
for d := time.Duration(0); d <= exitNodeResponseTimeout; d += 3 * time.Second {
now := start.Add(d)
e.recordActivity(k, now, false, false, 45*time.Second)
e.mu.Lock()
e.evaluateResponsiveness(now)
e.mu.Unlock()
}
w := b.HealthTracker().CurrentState().Warnings[exitNodeUnresponsiveWarnable.Code]
want := "The selected exit node \"my-gateway\" has not responded since " + start.WallTime().UTC().Format(time.RFC3339) + ". Internet connectivity may be affected."
if w.Text != want {
t.Fatalf("warning=%q want=%q", w.Text, want)
}
b.ForTest().SetState(ipn.NeedsMachineAuth)
e.recordActivity(k, start.Add(time.Minute), false, false, 45*time.Second)
if b.HealthTracker().IsUnhealthy(exitNodeUnresponsiveWarnable) {
t.Fatal("state change retained warning")
}
b.ForTest().SetState(ipn.Running)
b.ForTest().ApplyNetMap(nil)
if !e.responsiveness.key.IsZero() {
t.Fatal("netmap clear retained selection")
}
}
+1
View File
@@ -25,4 +25,5 @@ const (
HealthWarnableWarmingUp = "warming-up"
HealthWarnableTLSCertPending = "tls-cert-pending"
HealthWarnableExitNodeUnavailable = "exit-node-unavailable"
HealthWarnableExitNodeUnresponsive = "exit-node-unresponsive"
)
+6
View File
@@ -820,7 +820,9 @@ func (de *endpoint) scheduleHeartbeatForLifetimeLocked(after time.Duration, via
// sources are de.heartbeat() and de.probeUDPLifetimeCliffDoneLocked().
func (de *endpoint) heartbeatForLifetime() {
de.mu.Lock()
defer de.checkExitNodeResponsiveness()
defer de.mu.Unlock()
defer de.reportExitNodeHeartbeatStateLocked()
p := de.probeUDPLifetime
if p == nil || p.timer == nil {
// We raced with a code path trying to p.timer.Stop() us. Give up early
@@ -871,7 +873,10 @@ func (de *endpoint) heartbeatForLifetime() {
// lifetime on the tail end of an active session.
func (de *endpoint) heartbeat() {
de.mu.Lock()
// Record endpoint state, unlock, then inspect WireGuard counters.
defer de.checkExitNodeResponsiveness()
defer de.mu.Unlock()
defer de.reportExitNodeHeartbeatStateLocked()
if de.probeUDPLifetime != nil && de.probeUDPLifetime.timer != nil {
de.probeUDPLifetime.timer.Stop()
@@ -1950,6 +1955,7 @@ func (de *endpoint) handlePongConnLocked(m *disco.Pong, di *discoInfo, src epAdd
}
now := mono.Now()
de.c.reportExitNodePong(de.publicKey, now)
latency := now.Sub(sp.at)
if hasUDPTransport && !isDerp && !src.vni.IsSet() {
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_exitnodehealth && !ts_omit_health && !ts_omit_useexitnode
package magicsock
import (
"time"
"tailscale.com/feature"
"tailscale.com/tstime/mono"
"tailscale.com/types/key"
"tailscale.com/wgengine/wgint"
)
// HookExitNodeHeartbeatStateLocked records idle state and disco pong responses
// for exit node health monitoring under magicsock locks. It must not acquire
// backend or magicsock locks or call WireGuard.
var HookExitNodeHeartbeatStateLocked feature.Hook[func(*Conn, key.NodePublic, mono.Time, bool, bool, time.Duration)]
// HookExitNodeCheckResponsiveness checks WireGuard traffic counters
// and updates exit node responsiveness warnings after endpoint.mu is released.
// Only the enabled feature looks up counters and retains history; magicsock has
// no payload/state.
var HookExitNodeCheckResponsiveness feature.Hook[func(*Conn, key.NodePublic, func(key.NodePublic) (wgint.Peer, bool))]
func (de *endpoint) reportExitNodeHeartbeatStateLocked() {
if report, ok := HookExitNodeHeartbeatStateLocked.GetOk(); ok {
now := mono.Now()
idle := de.heartbeatDisabled || de.lastSendExt.IsZero() || now.Sub(de.lastSendExt) > sessionActiveTimeout ||
!de.c.havePrivateKey.Load() || de.c.networkDown()
report(de.c, de.publicKey, now, idle, false, sessionActiveTimeout)
}
}
func (de *endpoint) checkExitNodeResponsiveness() {
if report, ok := HookExitNodeCheckResponsiveness.GetOk(); ok {
report(de.c, de.publicKey, de.c.getPeerByKey)
}
}
func (c *Conn) reportExitNodePong(k key.NodePublic, now mono.Time) {
if report, ok := HookExitNodeHeartbeatStateLocked.GetOk(); ok {
report(c, k, now, false, true, sessionActiveTimeout)
}
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_exitnodehealth || ts_omit_health || ts_omit_useexitnode
package magicsock
import (
"tailscale.com/tstime/mono"
"tailscale.com/types/key"
)
func (*endpoint) reportExitNodeHeartbeatStateLocked() {}
func (*endpoint) checkExitNodeResponsiveness() {}
func (*Conn) reportExitNodePong(key.NodePublic, mono.Time) {}
@@ -0,0 +1,21 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_exitnodehealth || ts_omit_health || ts_omit_useexitnode
package magicsock
import (
"testing"
"tailscale.com/types/key"
)
func TestExitNodeReportsOmitted(t *testing.T) {
// Empty functions must not even dereference nil receivers.
var de *endpoint
de.reportExitNodeHeartbeatStateLocked()
de.checkExitNodeResponsiveness()
var c *Conn
c.reportExitNodePong(key.NodePublic{}, 0)
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_exitnodehealth && !ts_omit_health && !ts_omit_useexitnode
package magicsock
import (
"testing"
"time"
"tailscale.com/tstime/mono"
"tailscale.com/types/key"
"tailscale.com/wgengine/wgint"
)
func TestExitNodeReportLockBoundary(t *testing.T) {
c := newTestConn(t)
de := &endpoint{c: c, publicKey: key.NewNode().Public(), heartbeatDisabled: true}
locked, outside := 0, 0
restore := HookExitNodeHeartbeatStateLocked.SetForTest(func(*Conn, key.NodePublic, mono.Time, bool, bool, time.Duration) {
if de.mu.TryLock() {
de.mu.Unlock()
t.Error("heartbeat state reported without endpoint lock")
}
locked++
})
defer restore()
restoreOutside := HookExitNodeCheckResponsiveness.SetForTest(func(conn *Conn, k key.NodePublic, getPeer func(key.NodePublic) (wgint.Peer, bool)) {
conn.mu.Lock()
de.mu.Lock()
de.mu.Unlock()
conn.mu.Unlock()
outside++
})
defer restoreOutside()
// Exercise the real deferred callbacks, including the early-return paths.
de.heartbeat()
de.heartbeatForLifetime()
if locked != 2 || outside != 2 {
t.Fatal("split reports were not delivered")
}
if de.heartBeatTimer != nil || len(de.sentPing) != 0 || !de.lastSendAny.IsZero() {
t.Fatal("reports created work or traffic")
}
}