mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-23 03:37:19 +09:00
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:
1 parent
9c00fd0330
commit
852c3d1f0a
9 files changed
+530
-4
No files matched your search
@@ -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,7 +42,8 @@ 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.
|
||||
@@ -49,6 +53,7 @@ type extension struct {
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,5 @@ const (
|
||||
HealthWarnableWarmingUp = "warming-up"
|
||||
HealthWarnableTLSCertPending = "tls-cert-pending"
|
||||
HealthWarnableExitNodeUnavailable = "exit-node-unavailable"
|
||||
HealthWarnableExitNodeUnresponsive = "exit-node-unresponsive"
|
||||
)
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user