Skip to content

Commit ed93632

Browse files
committed
fix(daemon): in-window replay recovery, rekey relay fallback, FIN-not-RST keepalive reap
Three independent fleet-wedge fixes that compose into the rc7 release. 1. In-window replay-collision recovery (`pkg/daemon/keyexchange/`, `pkg/daemon/envelope/`, `pkg/daemon/tunnel.go`). When a peer restarts but its persisted X25519 identity carries over, the new daemon's send counter resets to 1 while our MaxRecvNonce still sits at the pre-restart watermark. Every subsequent frame from the peer lands at an already-marked counter in the replay bitmap and is dropped BEFORE AEAD-Open runs, so neither DecryptFailCount nor OutsideWindowCount increment. Pre- rc7, no recovery path engaged and the session wedged indefinitely (the 2026-05-11 list-agents bug). Fix: track ReplayCount per Crypto, gate via ShouldDropOnReplay (threshold=30, grace=3s, CompareAndDrop-guarded). On trip, drop the wedged Crypto + request rekey. Storm-safe — the grace window prevents the v1.9.1 rekey→fresh-session→relay-replay→rekey loop. 2. Rekey relay-fallback (`pkg/daemon/keyexchange/loop.go`, `pkg/daemon/tunnel.go`). When the local NAT remaps our external port across a daemon restart, peers' cached endpoints go stale; rekey PILA goes out fine, peer's PILA reply lands in a black hole. The blackhole heuristic needs BlackholeMissesRequired=3 observations × 4s = 12s to engage, but rekey gives up at 24s — too late for the existing routing flip to kick in. Fix: PreRetransmitHook fires once per retransmit; tunnel.go installs a hook that flips the peer to relay after RekeyRelayFallbackAfter=2 attempts. Existing ResetPendingRekeyAttempts handles the budget reset. 3. FIN-not-RST keepalive reap (`pkg/daemon/daemon.go`). When KeepaliveUnacked reached 3, the reaper constructed a raw RST inline and forced StateClosed. The RST poisoned the peer's session table for that conn_id, cascading into "connection refused" on the peer's next outbound. Observed fleet-wide on 2026-05-11 as bursts of N successful sends followed by total loss of reachability. Fix: extract keepaliveSweep; replace the raw RST with CloseConnection() (proper FIN with retx tracking, transitions to StateFinWait). Tests: - 5 envelope unit tests (bookkeeping, isolation, gate semantics, end-to-end, storm-safety) in zz_replay_recovery_test.go - 1 daemon unit test (TestSustainedReplayTriggersRekey) pinning the tunnel.go ErrReplay→drop+rekey wiring, plus updated TestReplayNeverTriggersRekey comment acknowledging the persistent-identity case - 3 daemon unit tests for the rekey relay-fallback hook (threshold flip, end-to-end via tick, below-threshold no-flip) - 3 daemon unit tests for the FIN-not-RST keepalive reap (FIN-not-RST regression, probe path unaffected, non-established conns skipped) - 1 real end-to-end test (`tests/zz_rc6_peer_restart_recovery_test.go`) spinning up beacon + registry + two daemons on loopback, exercising the full peer-restart recovery cycle through the driver API — no internal hooks, no white-box manipulation
1 parent 2efe049 commit ed93632

12 files changed

Lines changed: 1319 additions & 80 deletions

pkg/daemon/daemon.go

Lines changed: 71 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4339,61 +4339,78 @@ func (d *Daemon) idleSweepLoop() {
43394339
// resolveCache and hostnameCache: strictly TTL-gated on read; evict after 2× TTL.
43404340
d.reapCaches()
43414341

4342-
// Send keepalive probes to connections idle beyond keepalive interval.
4343-
// Dead-peer detection: if 3 consecutive probes go unanswered, RST + close.
4344-
idle := d.ports.IdleConnections(keepaliveInterval)
4345-
for _, conn := range idle {
4346-
conn.Mu.Lock()
4347-
st := conn.State
4348-
sendSeq := conn.SendSeq
4349-
recvAck := conn.RecvAck
4350-
kaUnacked := conn.KeepaliveUnacked
4351-
conn.Mu.Unlock()
4352-
if st != StateEstablished {
4353-
continue
4354-
}
4355-
if kaUnacked >= 3 {
4356-
slog.Warn("dead peer detected (3 keepalives unanswered), sending RST",
4357-
"conn_id", conn.ID, "remote_addr", conn.RemoteAddr, "remote_port", conn.RemotePort)
4358-
rst := &protocol.Packet{
4359-
Version: protocol.Version,
4360-
Flags: protocol.FlagRST,
4361-
Protocol: protocol.ProtoStream,
4362-
Src: conn.LocalAddr,
4363-
Dst: conn.RemoteAddr,
4364-
SrcPort: conn.LocalPort,
4365-
DstPort: conn.RemotePort,
4366-
}
4367-
d.tunnels.Send(conn.RemoteAddr.Node, rst)
4368-
conn.Mu.Lock()
4369-
conn.State = StateClosed
4370-
conn.Mu.Unlock()
4371-
conn.CloseRecvBuf()
4372-
d.ports.RemoveConnection(conn.ID)
4373-
d.publishEvent("conn.dead_peer", map[string]interface{}{
4374-
"remote_addr": conn.RemoteAddr.String(), "remote_port": conn.RemotePort,
4375-
"local_port": conn.LocalPort, "conn_id": conn.ID,
4376-
})
4377-
continue
4378-
}
4379-
conn.Mu.Lock()
4380-
conn.KeepaliveUnacked++
4381-
conn.Mu.Unlock()
4382-
probe := &protocol.Packet{
4383-
Version: protocol.Version,
4384-
Flags: protocol.FlagACK,
4385-
Protocol: protocol.ProtoStream,
4386-
Src: conn.LocalAddr,
4387-
Dst: conn.RemoteAddr,
4388-
SrcPort: conn.LocalPort,
4389-
DstPort: conn.RemotePort,
4390-
Seq: sendSeq,
4391-
Ack: recvAck,
4392-
Window: conn.RecvWindow(),
4393-
}
4394-
d.tunnels.Send(conn.RemoteAddr.Node, probe)
4395-
}
4342+
// Keepalive probes + dead-peer detection. See keepaliveSweep
4343+
// for details. Extracted into its own method so tests can
4344+
// drive a single tick without standing up the full ticker.
4345+
d.keepaliveSweep(keepaliveInterval)
4346+
}
4347+
}
4348+
}
4349+
4350+
// keepaliveSweep runs one iteration of the keepalive-probe / dead-peer
4351+
// detection loop. For each connection idle beyond `keepaliveInterval`:
4352+
//
4353+
// - If it's in StateEstablished and 3 prior probes already went
4354+
// unanswered, close it politely via CloseConnection (sends FIN
4355+
// with retx tracking, transitions to StateFinWait). This is
4356+
// intentionally NOT a RST: an idle StateEstablished connection
4357+
// is the expected steady-state after a successful request/reply
4358+
// cycle (pilot's pseudo-TCP has no half-close API), so RST'ing
4359+
// it poisons the peer's session table for that conn_id and
4360+
// cascades into "dial: connection refused" on the peer's next
4361+
// outbound to us. Observed fleet-wide on 2026-05-11 as bursts
4362+
// of N successful sends followed by total loss of reachability
4363+
// for a peer; root cause was the prior inline-RST keepalive
4364+
// reaper. CloseConnection's FIN lets the peer cleanly transition
4365+
// FIN-ACK → StateTimeWait → reap with no poisoning.
4366+
//
4367+
// - Otherwise, increment KeepaliveUnacked and send a probe packet
4368+
// (FlagACK only). The probe doubles as a NAT-keepalive: it bumps
4369+
// the consumer-NAT idle timer for our external mapping every
4370+
// keepaliveInterval, holding the inbound path open between
4371+
// application-driven sends.
4372+
//
4373+
// Exposed (unexported, same-package) so the white-box tests in
4374+
// zz_keepalive_fin_not_rst_test.go can drive a single tick without
4375+
// the ticker-loop overhead.
4376+
func (d *Daemon) keepaliveSweep(keepaliveInterval time.Duration) {
4377+
idle := d.ports.IdleConnections(keepaliveInterval)
4378+
for _, conn := range idle {
4379+
conn.Mu.Lock()
4380+
st := conn.State
4381+
sendSeq := conn.SendSeq
4382+
recvAck := conn.RecvAck
4383+
kaUnacked := conn.KeepaliveUnacked
4384+
conn.Mu.Unlock()
4385+
if st != StateEstablished {
4386+
continue
4387+
}
4388+
if kaUnacked >= 3 {
4389+
slog.Info("idle peer reaped (3 keepalives unanswered, sending FIN)",
4390+
"conn_id", conn.ID, "remote_addr", conn.RemoteAddr, "remote_port", conn.RemotePort)
4391+
d.publishEvent("conn.dead_peer", map[string]interface{}{
4392+
"remote_addr": conn.RemoteAddr.String(), "remote_port": conn.RemotePort,
4393+
"local_port": conn.LocalPort, "conn_id": conn.ID,
4394+
})
4395+
d.CloseConnection(conn)
4396+
continue
4397+
}
4398+
conn.Mu.Lock()
4399+
conn.KeepaliveUnacked++
4400+
conn.Mu.Unlock()
4401+
probe := &protocol.Packet{
4402+
Version: protocol.Version,
4403+
Flags: protocol.FlagACK,
4404+
Protocol: protocol.ProtoStream,
4405+
Src: conn.LocalAddr,
4406+
Dst: conn.RemoteAddr,
4407+
SrcPort: conn.LocalPort,
4408+
DstPort: conn.RemotePort,
4409+
Seq: sendSeq,
4410+
Ack: recvAck,
4411+
Window: conn.RecvWindow(),
43964412
}
4413+
d.tunnels.Send(conn.RemoteAddr.Node, probe)
43974414
}
43984415
}
43994416

pkg/daemon/envelope/envelope.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ func DecryptFrame(store *keyexchange.Store, data []byte) DecryptResult {
164164
c.ReplayMu.Lock()
165165
c.OutsideWindowCount++
166166
c.ReplayMu.Unlock()
167+
} else {
168+
// In-window replay collision. Symmetric counterpart to
169+
// OutsideWindowCount on the *other* side of the window:
170+
// peer's counter is INSIDE [max-window, max] but at a
171+
// position we've already marked. Sustained collisions mean
172+
// the peer's send counter reset (peer restarted with a
173+
// persistent X25519 identity, so no PILA was negotiated)
174+
// and every frame they now produce lands on a bit we
175+
// already set. Recovery is structurally identical to
176+
// outside-window: gate via ShouldDropOnReplay (threshold +
177+
// grace), then drop the Crypto and trigger a fresh exchange.
178+
c.ReplayMu.Lock()
179+
c.ReplayCount++
180+
c.ReplayMu.Unlock()
167181
}
168182
return DecryptResult{
169183
PeerNodeID: peerNodeID,
@@ -192,14 +206,17 @@ func DecryptFrame(store *keyexchange.Store, data []byte) DecryptResult {
192206
}
193207
}
194208

195-
// Successful decrypt — reset both fault counters under one lock.
209+
// Successful decrypt — reset all three fault counters under one lock.
196210
c.ReplayMu.Lock()
197211
if c.DecryptFailCount != 0 {
198212
c.DecryptFailCount = 0
199213
}
200214
if c.OutsideWindowCount != 0 {
201215
c.OutsideWindowCount = 0
202216
}
217+
if c.ReplayCount != 0 {
218+
c.ReplayCount = 0
219+
}
203220
c.ReplayMu.Unlock()
204221

205222
return DecryptResult{

0 commit comments

Comments
 (0)