Skip to content

daemon: reliability + policy hardening (WSS half-open, watchdog restart-loop, port-policy fail-closed)#388

Open
TeoSlayer wants to merge 4 commits into
mainfrom
fix/daemon-reliability-hardening
Open

daemon: reliability + policy hardening (WSS half-open, watchdog restart-loop, port-policy fail-closed)#388
TeoSlayer wants to merge 4 commits into
mainfrom
fix/daemon-reliability-hardening

Conversation

@TeoSlayer

Copy link
Copy Markdown
Collaborator

Three independent daemon fixes surfaced by a liveness/fail-open sweep, each verified against current `main` (post-#387) with tests.

1. WSS idle-ping bounded + reconnect on half-open beacon conn

`idlePing` called `conn.Ping(lifetimeCtx)` — unbounded — while holding `writeMu`. On a silently half-open conn (stale NAT/relay mapping, half-open TCP), the pong never arrives, so the ping blocked forever: the keepalive loop wedged, every `Send` froze behind `writeMu`, and the dead beacon link was never detected (`drainReads` sits on a Read that never returns; `Send` only errors once there's traffic). This is the WSS-transport analogue of the L4 rx-watchdog's silent-inbound wedge.

Now each ping is bounded by `IdlePingTimeout` (default 10s, well under the 30s interval); on timeout the conn is force-closed so the supervisor's `drainReads` unblocks and reconnect fires. Test: `TestIdlePing_HalfOpenConnTriggersReconnect`.

2. rx-watchdog restart-loop circuit breaker

The hard exit (code 86 → supervisor respawn) assumes a restart CLEARS the wedge. When it can't (a NAT/firewall/topology change no restart fixes), the daemon boot-loops every ~30-40 min forever, dropping every live tunnel each cycle, while the TCP registry heartbeat keeps the unit "active" so nothing alerts.

Now hard exits are recorded to a sidecar file next to the identity (survives respawn). After `rxWedgeLoopMax` (3) exits within `rxWedgeLoopWindow` (2h) the breaker opens: stay up, keep soft-recovering, emit `tunnel.rx_wedge_restart_loop`. No persistence ⇒ breaker off (unchanged behaviour). Tests cover open/closed/stale-window/no-persistence + the sidecar parser.

3. Network port policy fails CLOSED on registry error

`loadNetworkPolicies` `continue`d past any `GetNetworkPolicy` error, dropping that network's entry; `isPortAllowed` reads a missing entry as allow-all — so a restricted network went silently unrestricted on any transient registry hiccup (fail-OPEN). It also discarded a successful-empty response identically.

Now: snapshot the prior cache; a failed fetch retains the last-known policy (fail-closed) with bounded retry + `network.policy_load_failed` event; a successful fetch is authoritative including an empty list (the only thing that relaxes a restriction). Decision extracted as the pure `mergeNetworkPolicies`, unit-tested by `TestMergeNetworkPolicies_FailClosed`.

Testing

`pkg/daemon/...` green under `-race`; the two changed transport/daemon suites verified with the race detector. No behaviour change on the happy path.

Ships toward v1.12.7 (v1.12.6 already released with #381/#374/#387).

🤖 Generated with Claude Code

teovl and others added 3 commits July 15, 2026 18:05
The idle keepalive called conn.Ping(lifetimeCtx) — unbounded — while
holding writeMu. On a silently half-open conn (stale NAT/relay mapping,
half-open TCP), the pong never arrives, so the ping blocked forever: the
keepalive loop wedged, every Send froze behind writeMu, and the dead
beacon link was never detected. WSS-path analogue of the L4 rx-watchdog.

Bound each ping with IdlePingTimeout (default 10s, well under the 30s
interval). On timeout, force the conn closed so the supervisor's
drainReads unblocks and reconnect fires; clear t.conn so Send fails fast.

Test: TestIdlePing_HalfOpenConnTriggersReconnect drives a beacon that
goes silent after auth (never pongs); asserts reconnect. wss green -race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
loadNetworkPolicies rebuilt the port-policy cache from scratch each call
and 'continue'd past any GetNetworkPolicy error, dropping that network's
entry. isPortAllowed reads a missing entry as allow-all (empty list ==
no restriction), so a network that DOES restrict ports went silently
unrestricted on any transient registry hiccup at load — a fail-OPEN
hole. It also discarded a successful-empty response identically, making
'registry failed' indistinguishable from 'no restriction'.

Now: snapshot the prior cache; on a failed fetch retain the last-known
policy (fail-closed) with a bounded retry + Warn + network.policy_load_
failed event; on success store the result verbatim, including an empty
list (the only thing that legitimately relaxes a restriction). The merge
decision is extracted as the pure mergeNetworkPolicies so the fail-closed
invariant is unit-tested (TestMergeNetworkPolicies_FailClosed) without a
live registry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hard exit (code 86 → launchd/systemd respawn) assumes a restart
CLEARS the inbound wedge (stale NAT/relay mapping). When it can't — a
NAT/firewall/topology change no restart fixes — the daemon boot-loops
forever: wedge → 3 soft attempts → exit → respawn → wedge → …, every
~30-40 min, dropping every live tunnel each cycle while the registry
heartbeat keeps the unit 'active' so nothing alerts.

Add a cross-restart circuit breaker. Hard exits are recorded to a
sidecar file next to the identity (survives the respawn). Once
rxWedgeLoopMax (3) exits have occurred within rxWedgeLoopWindow (2h),
the breaker OPENS: withhold further exits, stay up, keep soft-recovering,
and emit tunnel.rx_wedge_restart_loop so an operator sees the stuck host.
Empty IdentityPath (no persistence) disables the breaker — behaviour
unchanged there.

Tests: breaker opens at the threshold (no exit), stays closed below it
(exit fires + is recorded), ignores exits outside the window, is off
without persistence; plus the sidecar parser's window/malformed handling.
pkg/daemon green under -race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread pkg/daemon/rxwatchdog.go Fixed
The path comes from the daemon's own IdentityPath, not peer input;
annotate so the diff-scoped gosec gate passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants