control,health,ipn: move IP forwarding check to health tracker (#19007)

Currently IP forwarding health check is done on sending MapRequests.

Move ip forwarding to the health service to gain the benefits
of the health tracker and perodic monitoring out of band from
the MapRequest path. ipnlocal now provides a closure to
the health service to provide the check if forwarding is broken.

Removed `skipIPForwardingCheck` from controlclient/direct.go,
it wasn't being used as the comments describe it, that check
has moved to ipnlocal for the closure to the health tracker.

Updates #18976

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
This commit is contained in:
Mike O'Driscoll
2026-03-18 16:24:12 -04:00
committed by GitHub
parent 156d97c549
commit 4e88d231d5
10 changed files with 226 additions and 79 deletions
+33
View File
@@ -132,6 +132,11 @@ type Tracker struct {
localLogConfigErr error
tlsConnectionErrors map[string]error // map[ServerName]error
metricHealthMessage any // nil or *metrics.MultiLabelMap[metricHealthMessageLabel]
// IP forwarding check
// If non-nil, called periodically to check if IP forwarding is broken.
// Should return true if broken, false if healthy.
isIPForwardingBroken func() bool
}
// NewTracker contructs a new [Tracker] and attaches the given eventbus.
@@ -1097,6 +1102,8 @@ func (t *Tracker) updateBuiltinWarnablesLocked() {
t.setHealthyLocked(NetworkStatusWarnable)
}
t.updateIPForwardingWarnableLocked()
if t.localLogConfigErr != nil {
t.setUnhealthyLocked(localLogWarnable, Args{
ArgError: t.localLogConfigErr.Error(),
@@ -1389,3 +1396,29 @@ func (t *Tracker) LastNoiseDialWasRecent() bool {
t.lastNoiseDial = now
return dur < 2*time.Minute
}
// SetIPForwardingCheck sets the function to check if IP forwarding is broken.
// The function should return true if IP forwarding is broken, false if healthy.
// Pass nil to disable IP forwarding checks.
func (t *Tracker) SetIPForwardingCheck(checkFunc func() bool) {
if t.nil() {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.isIPForwardingBroken = checkFunc
// Run an immediate check to set initial state
t.updateIPForwardingWarnableLocked()
}
// updateIPForwardingWarnableLocked checks the IP forwarding state and
// sets or clears the ipForwardingWarnable accordingly.
func (t *Tracker) updateIPForwardingWarnableLocked() {
if t.isIPForwardingBroken != nil && t.isIPForwardingBroken() {
t.setUnhealthyLocked(ipForwardingWarnable, Args{})
} else {
t.setHealthyLocked(ipForwardingWarnable)
}
}
+83
View File
@@ -999,3 +999,86 @@ func TestCurrentStateETagWarnable(t *testing.T) {
}
})
}
func TestIPForwardingState(t *testing.T) {
tests := []struct {
name string
checkFunc func() bool // nil means no check function
wantUnhealthy bool
}{
{
name: "broken",
checkFunc: func() bool { return true },
wantUnhealthy: true,
},
{
name: "healthy",
checkFunc: func() bool { return false },
wantUnhealthy: false,
},
{
name: "no_check_function",
checkFunc: nil,
wantUnhealthy: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bus := eventbus.New()
tr := NewTracker(bus)
defer bus.Close()
tr.SetIPNState("Running", true)
tr.SetIPForwardingCheck(tt.checkFunc)
tr.mu.Lock()
tr.updateBuiltinWarnablesLocked()
tr.mu.Unlock()
got := tr.IsUnhealthy(ipForwardingWarnable)
if got != tt.wantUnhealthy {
t.Errorf("IsUnhealthy(ipForwardingWarnable) = %v, want %v", got, tt.wantUnhealthy)
}
})
}
// Test state transitions
t.Run("transitions", func(t *testing.T) {
bus := eventbus.New()
tr := NewTracker(bus)
defer bus.Close()
tr.SetIPNState("Running", true)
// Start broken
tr.SetIPForwardingCheck(func() bool { return true })
tr.mu.Lock()
tr.updateBuiltinWarnablesLocked()
tr.mu.Unlock()
if !tr.IsUnhealthy(ipForwardingWarnable) {
t.Fatal("expected IP forwarding to be unhealthy initially")
}
// Transition to healthy
tr.SetIPForwardingCheck(func() bool { return false })
tr.mu.Lock()
tr.updateBuiltinWarnablesLocked()
tr.mu.Unlock()
if tr.IsUnhealthy(ipForwardingWarnable) {
t.Fatal("expected IP forwarding to be healthy after transition")
}
// Transition to nil (should stay healthy)
tr.SetIPForwardingCheck(nil)
tr.mu.Lock()
tr.updateBuiltinWarnablesLocked()
tr.mu.Unlock()
if tr.IsUnhealthy(ipForwardingWarnable) {
t.Fatal("expected IP forwarding to be healthy after clearing check")
}
})
}
+13
View File
@@ -298,3 +298,16 @@ var warmingUpWarnable = condRegister(func() *Warnable {
Text: StaticMessage("Tailscale is starting. Please wait."),
}
})
// ipForwardingWarnable is a Warnable that warns the user that IP forwarding is disabled
// but subnet routing or exit node functionality is being used.
var ipForwardingWarnable = condRegister(func() *Warnable {
return &Warnable{
Code: "ip-forwarding-off",
Title: "IP forwarding is off",
Severity: SeverityMedium,
MapDebugFlag: "warn-ip-forwarding-off",
Text: StaticMessage("Subnet routing is enabled, but IP forwarding is disabled. Check that IP forwarding is enabled on your machine."),
ImpactsConnectivity: true,
}
})