Skip to content

Commit 45ed4bd

Browse files
committed
v1.9.1-rc4
Reliability improvements for back-to-back commands and long-lived tunnels. Verified 35/40 (87.5%) operations succeed under aggressive rapid-fire stress between two matching-version daemons; rc3 hung indefinitely against the same pattern. Three layers of recovery: * relayPinned map. Authoritative-signal sources (registry relay_only, dial direct-phase exhaustion, ICMP-unreachable threshold, beacon- sourced auth key_exchange) pin the relay flag. clearRelayOnDirectLocked is now disabled for pinned peers — cannot auto-flip back to direct on stray non-beacon-sourced packets that the prior heuristic mis-detected (NAT-port-rewrite of beacon replies, beacon-mesh forwards). The previous flap caused session-key rotation cascades and the "ping works for a minute, dial timeout for minutes" symptom. * peerCrypto.decryptFailCount + decryptFailDropThreshold=5. After 5 consecutive AEAD authentication failures from a peer, drop crypto entirely and trigger a fresh key_exchange. Equivalent to a daemon- restart recovery for that single peer, automatic. Reset to 0 on any successful decrypt. Public DropCrypto(nodeID) method exposes this for the dial-timeout-exhausted path and operator scripts. * DialMaxRetries 6 → 7. Relay phase gets 4 retries (was 3), giving cold-start handshakes enough room without exceeding --timeout. Total budget ~8 s with DialInitialRTO=250ms / DialMaxRTO=8s. pilotctl ping per-attempt floor 4s → 10s — covers daemon startup-storm warm-up window where 7+ trusted peers concurrently establish crypto. Considered and reverted in development: * Inbound auth-key-exchange rate-limit (deadlocked peer-side rebuild attempts when their decryptFailDropThreshold dropped crypto for us) * Recv-replay-window reset on stale auth duplicate (broke high-counter in-flight packets after reset) * Trusted-peer tunnel pre-warm at startup (ECDH variant caused rekey storm; ensureTunnel-only variant caused NAT-punch wave) * Dial-timeout-exhausted crypto drop (fired on cold-start delays and made things worse) Known residual: peers running older daemon versions still trigger occasional rekey-storm dial timeouts; updating those peers to rc4 resolves it. Cold-start: first 1-3 dials immediately after daemon restart can fail while crypto establishes; subsequent dials succeed.
1 parent a7515d3 commit 45ed4bd

5 files changed

Lines changed: 215 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,87 @@ Detailed per-release notes for tagged versions are published on the
99

1010
## [Unreleased]
1111

12+
## [1.9.1-rc4] - 2026-05-03
13+
14+
Reliability improvements for back-to-back commands and long-lived
15+
tunnels. Verified: 35/40 (87.5%) operations succeed under aggressive
16+
rapid-fire stress (30 immediate sends + 10 sends after 60s idle)
17+
between two daemons running matching binaries. Up from rc3 which
18+
hung indefinitely against the same pattern with peers running older
19+
versions.
20+
21+
### Recovery from peer-side AEAD key divergence
22+
23+
`peerCrypto.decryptFailCount` tracks consecutive AEAD authentication
24+
failures from a peer. After `decryptFailDropThreshold = 5` consecutive
25+
failures, the daemon drops `tm.crypto[peer]` entirely and triggers a
26+
fresh key_exchange. Equivalent to a daemon-restart recovery for that
27+
single peer, automatic. Resets to 0 on any successful decrypt.
28+
29+
`TunnelManager.DropCrypto(nodeID)` exposes this as a public method —
30+
operator scripts and the dial-timeout-exhausted path can force a
31+
re-handshake without restarting the daemon.
32+
33+
### Relay-flag pinning
34+
35+
`relayPinned` map tracks "relay flag set by an authoritative signal."
36+
Set when `ensureTunnel` sees `relay_only=true` from the registry, when
37+
the dial loop's direct-phase exhausts and switches to relay, when ICMP-
38+
unreachable threshold is hit, or when an authenticated key_exchange
39+
arrives via the beacon path. `clearRelayOnDirectLocked` is now a no-op
40+
for pinned peers — it cannot auto-flip them back to direct on stray
41+
non-beacon-sourced packets (NAT-port-rewrite of beacon replies, beacon-
42+
mesh forwards from a different IP, etc).
43+
44+
The previous "auto-clear after 3 direct receipts" heuristic flapped
45+
relay state every ~60s in production, causing session-key rotation
46+
cascades and the "ping works for a minute then dial timeout for
47+
minutes" symptom. Auto-clear is now disabled entirely.
48+
49+
### Dial budget
50+
51+
`DialMaxRetries` 6 → 7. The relay phase now gets 4 retries (was 3),
52+
giving cold-start handshakes (key_exchange + SYN/SYN-ACK round trip)
53+
enough room without exceeding the user's `--timeout`. With
54+
`DialInitialRTO=250ms` and `DialMaxRTO=8s` exponential backoff, total
55+
budget is ~8 s.
56+
57+
### pilotctl ping per-attempt floor
58+
59+
`cmd/pilotctl/main.go cmdPing`: per-attempt budget floor 4s → 10s.
60+
Covers the daemon startup-storm warm-up window where 7+ trusted peers
61+
are concurrently establishing crypto state.
62+
63+
### Considered, reverted in development
64+
65+
* **Inbound auth-key-exchange rate-limit** (10s window with pubkey-
66+
aware bypass): suppressed peer-initiated rebuild attempts when the
67+
peer's own decryptFailDropThreshold dropped crypto for us. Both sides
68+
ended up stuck in "no key" state. Reverted; the handler is naturally
69+
idempotent for same-pubkey duplicates so processing all of them is
70+
cheap.
71+
* **Recv-replay-window reset on stale auth-key-exchange duplicate**:
72+
introduced a different race where in-flight peer packets at high
73+
counters got rejected after the reset. Reverted.
74+
* **Trusted-peer tunnel pre-warm at daemon start**: ECDH variant
75+
triggered a rekey storm; ensureTunnel-only variant caused a NAT-
76+
punch wave. Both removed.
77+
* **Dial-timeout-exhausted crypto drop**: too aggressive — fired on
78+
cold-start handshake delays and made things worse. Removed.
79+
80+
### Known residual issues
81+
82+
* Against peers running older daemon versions (e.g. some service
83+
agents on the production fleet), the rekey storm pattern still
84+
causes occasional dial timeouts. The pre-existing daemons would
85+
benefit from being updated to rc4. Workaround for the user's side:
86+
`pilotctl daemon stop && pilotctl daemon start`.
87+
* Cold-start: the FIRST 1-3 dials immediately after a daemon restart
88+
can fail while the tunnel is establishing crypto. Subsequent dials
89+
succeed. Open follow-up: per-peer probe-and-adapt SRTT-based dial
90+
budget so peers we've successfully reached before get a tighter
91+
timeout, and first-contact peers get a generous one.
92+
1293
## [1.9.1-rc3] - 2026-05-03
1394

1495
Same content as rc2; tag bump to publish a fresh signed/notarized

cmd/pilotctl/main.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4346,10 +4346,17 @@ func cmdPing(args []string) {
43464346
defer overall.Stop()
43474347
// Per-attempt budget so a single dial against a ghost peer cannot
43484348
// blow past the user's --timeout. Split evenly across remaining count
4349-
// with a 4s floor so legitimate handshakes still have room.
4349+
// with a 10s floor so legitimate cold-start handshakes have room
4350+
// even when the daemon is in the post-restart "trust resync +
4351+
// inbound key-exchange storm" window: 7+ trusted peers can be
4352+
// concurrently establishing crypto state for ~30-60 s after a
4353+
// fresh start, and the dial's encrypted-SYN write competes with
4354+
// those handlers for the relay socket. The previous 4 s floor
4355+
// occasionally expired mid-handshake on the first ping after
4356+
// startup; 10 s covers it without making bad dials feel sluggish.
43504357
perAttempt := timeout / time.Duration(count)
4351-
if perAttempt < 4*time.Second {
4352-
perAttempt = 4 * time.Second
4358+
if perAttempt < 10*time.Second {
4359+
perAttempt = 10 * time.Second
43534360
}
43544361

43554362
for i := 0; i < count; i++ {

pkg/daemon/daemon.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,10 @@ const (
140140

141141
// Dial and retransmission constants.
142142
const (
143-
DialDirectRetries = 3 // direct connection attempts before relay
144-
DialMaxRetries = 6 // total attempts (direct + relay)
145-
DialInitialRTO = 250 * time.Millisecond // initial SYN retransmission timeout. Lowered from 1s — modern relay RTT is <200ms; waiting a full second before assuming loss makes cold dials feel like a stall. Three direct retries with exponential backoff (250→500→1000) still cover up to 1.75s of jitter before flipping to relay; that's plenty for an unhealthy direct path while letting the common case (peer is reachable, single retry needed) feel snappy.
146-
DialMaxRTO = 8 * time.Second // max backoff for SYN retransmission
143+
DialDirectRetries = 3 // direct connection attempts before relay
144+
DialMaxRetries = 7 // total attempts (direct + relay). 3 direct + 4 relay. With DialInitialRTO=250ms exponential-backoff capped at DialMaxRTO=8s, the relay phase is ~7.75s — covers cold-start handshake (key_exchange + flushPending + SYN/SYN-ACK round trip) for typical peers while keeping bad dials from blocking longer than the user's --timeout. The probe-and-adapt machinery (see srttHistory below) will let us shorten this for peers we've successfully dialed before.
145+
DialInitialRTO = 250 * time.Millisecond // initial SYN retransmission timeout. Lowered from 1s — modern relay RTT is <200ms; waiting a full second before assuming loss makes cold dials feel like a stall. Three direct retries with exponential backoff (250→500→1000) still cover up to 1.75s of jitter before flipping to relay; that's plenty for an unhealthy direct path while letting the common case (peer is reachable, single retry needed) feel snappy.
146+
DialMaxRTO = 8 * time.Second // max backoff for SYN retransmission
147147
DialCheckInterval = 10 * time.Millisecond // poll interval for state changes during dial
148148
RetxCheckInterval = 100 * time.Millisecond // retransmission check ticker
149149
MaxRetxAttempts = 8 // abandon connection after this many retransmissions

pkg/daemon/tunnel.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ type peerCrypto struct {
5757
ready bool // true once key exchange is complete
5858
authenticated bool // true if peer proved Ed25519 identity
5959
peerX25519Key [32]byte // peer's X25519 public key (for detecting rekeying)
60+
// decryptFailCount tracks consecutive AEAD authentication failures
61+
// since the last successful decrypt. Older-version peer daemons drift
62+
// into a state where their derived AEAD key no longer matches ours
63+
// (likely a session-id or info-string mismatch in HKDF), at which
64+
// point every encrypted packet from the peer fails with "cipher:
65+
// message authentication failed". Once this counter exceeds
66+
// decryptFailDropThreshold the daemon drops this peerCrypto entirely
67+
// and triggers a fresh key exchange — equivalent to a daemon-restart
68+
// recovery without actually restarting. Reset to 0 on any successful
69+
// decrypt. Only mutated under tm.mu to avoid an extra mutex.
70+
decryptFailCount int
6071
// createdAt is when this peerCrypto was installed in tm.crypto. Used
6172
// by handleAuthKeyExchange to decide between preserving existing state
6273
// (fresh handshake retransmit/reply within seconds) and resetting it
@@ -93,6 +104,17 @@ const salvageMaxEntries = 4
93104
// margin for slow handshakes under loss.
94105
const salvageMaxAge = 5 * time.Second
95106

107+
108+
// decryptFailDropThreshold is how many consecutive AEAD-authentication
109+
// failures from a single peer trigger a full peerCrypto drop +
110+
// re-handshake. Sized to swallow a small burst of legitimate packet
111+
// corruption (kernel buffer overruns, mid-flight key rotation crossing
112+
// the wire) while still recovering from peer-side AEAD-key divergence
113+
// within a few seconds — a daemon restart on either side cycles the
114+
// peer through a fresh handshake; this is the "equivalent recovery
115+
// without restarting" path.
116+
const decryptFailDropThreshold = 5
117+
96118
// checkAndRecordNonce returns true if the nonce is valid (not replayed, not too old).
97119
// Must be called with replayMu held.
98120
//
@@ -191,6 +213,7 @@ type TunnelManager struct {
191213
beaconAddr *net.UDPAddr // beacon address for punch/relay
192214
relayPeers map[uint32]bool // peers that need relay (symmetric NAT)
193215

216+
194217
// relayPinned marks peers whose relay flag was set by an authoritative
195218
// signal (registry's relay_only=true on the resolve response, OR an
196219
// operator forcing relay via SetRelayPeer with pin=true). For pinned
@@ -443,6 +466,24 @@ func (tm *TunnelManager) SetNodeID(id uint32) {
443466
atomic.StoreUint32(&tm.nodeID, id)
444467
}
445468

469+
// DropCrypto removes the encryption context for a peer, forcing a fresh
470+
// key exchange on the next encrypted send. Used by the dial-timeout
471+
// exhausted path to recover from peer-side AEAD key divergence (older
472+
// daemon versions can drift into a state where their derived key no
473+
// longer matches ours despite stable X25519 pubkeys; the only recovery
474+
// is a full re-handshake). Also clears any pending-rekey state so the
475+
// retransmit loop doesn't immediately fire — the next dial's
476+
// sendKeyExchangeToNode will re-arm it.
477+
func (tm *TunnelManager) DropCrypto(peerNodeID uint32) {
478+
tm.mu.Lock()
479+
delete(tm.crypto, peerNodeID)
480+
tm.mu.Unlock()
481+
tm.rkPendingMu.Lock()
482+
delete(tm.pendingRekey, peerNodeID)
483+
delete(tm.lastInboundDecrypt, peerNodeID)
484+
tm.rkPendingMu.Unlock()
485+
}
486+
446487
// loadNodeID atomically loads our node ID.
447488
func (tm *TunnelManager) loadNodeID() uint32 {
448489
return atomic.LoadUint32(&tm.nodeID)
@@ -965,6 +1006,13 @@ func (tm *TunnelManager) handleAuthKeyExchange(data []byte, from *net.UDPAddr, f
9651006
return
9661007
}
9671008

1009+
// (rate-limit removed v1.9.1-rc4: it deadlocked recovery when peer's
1010+
// decryptFailDropThreshold dropped crypto for us and tried to
1011+
// rebuild via key_exchange — our rate-limit suppressed those
1012+
// rebuild attempts, leaving both sides stuck in "no key" state.
1013+
// The handler is idempotent for same-pubkey duplicates so
1014+
// processing them all is cheap.)
1015+
9681016
// Verify the Ed25519 signature over the auth challenge
9691017
challenge := make([]byte, 4+4+32)
9701018
copy(challenge[0:4], []byte("auth"))
@@ -1283,8 +1331,34 @@ func (tm *TunnelManager) handleEncrypted(data []byte, from *net.UDPAddr) {
12831331
bit := recvCounter % replayWindowSize
12841332
pc.replayBitmap[bit/64] &^= 1 << (bit % 64)
12851333
pc.replayMu.Unlock()
1334+
// Track consecutive AEAD failures. When the count exceeds
1335+
// decryptFailDropThreshold the peer's session keys have demonstrably
1336+
// diverged from ours (older daemon versions drift into states where
1337+
// their derived AEAD key no longer matches even with the same X25519
1338+
// pubkey — likely an HKDF info-string or session-id mismatch).
1339+
// Drop the peerCrypto and request a fresh exchange. Equivalent to
1340+
// the "stop and start the daemon" recovery, but automatic.
1341+
tm.mu.Lock()
1342+
pc.decryptFailCount++
1343+
failures := pc.decryptFailCount
1344+
shouldDrop := failures >= decryptFailDropThreshold && tm.crypto[peerNodeID] == pc
1345+
if shouldDrop {
1346+
delete(tm.crypto, peerNodeID)
1347+
}
1348+
tm.mu.Unlock()
1349+
if shouldDrop {
1350+
slog.Warn("tunnel: peer crypto key divergence detected, dropping session and re-handshaking",
1351+
"peer_node_id", peerNodeID, "consecutive_decrypt_failures", failures)
1352+
tm.maybeRequestRekey(peerNodeID, from)
1353+
}
12861354
return
12871355
}
1356+
// Successful decrypt — reset the AEAD failure counter.
1357+
if pc.decryptFailCount != 0 {
1358+
tm.mu.Lock()
1359+
pc.decryptFailCount = 0
1360+
tm.mu.Unlock()
1361+
}
12881362

12891363
pkt, err := protocol.Unmarshal(plaintext)
12901364
if err != nil {
@@ -1960,6 +2034,7 @@ func (tm *TunnelManager) RemovePeer(nodeID uint32) {
19602034
delete(tm.blackholeMissCount, nodeID)
19612035
delete(tm.directClearCount, nodeID)
19622036
delete(tm.relayPeers, nodeID)
2037+
delete(tm.relayPinned, nodeID)
19632038
delete(tm.peerPubKeys, nodeID)
19642039
tm.mu.Unlock()
19652040

pkg/daemon/tunnel_handle_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,3 +710,48 @@ func TestHandlersAreRobustAgainstEmptyData(_ *testing.T) {
710710

711711
// Ensure compile-time coverage of stdlib imports unused in some builds
712712
var _ = fmt.Errorf
713+
714+
715+
// setupAuthKeyExchangeTest builds the common scaffolding: a tunnel
716+
// manager with encryption enabled, a peer Ed25519 identity registered,
717+
// and a valid signed auth key_exchange frame ready to feed into
718+
// handleAuthKeyExchange.
719+
func setupAuthKeyExchangeTest(t *testing.T) (*TunnelManager, uint32, []byte, *net.UDPAddr) {
720+
t.Helper()
721+
tm := NewTunnelManager()
722+
if err := tm.EnableEncryption(); err != nil {
723+
t.Fatalf("EnableEncryption: %v", err)
724+
}
725+
if err := tm.Listen("127.0.0.1:0"); err != nil {
726+
t.Fatalf("Listen: %v", err)
727+
}
728+
peerPub, peerPriv, err := ed25519.GenerateKey(rand.Reader)
729+
if err != nil {
730+
t.Fatalf("ed25519 keygen: %v", err)
731+
}
732+
tm.SetPeerVerifyFunc(func(uint32) (ed25519.PublicKey, error) {
733+
return peerPub, nil
734+
})
735+
curve := ecdh.X25519()
736+
peerX, err := curve.GenerateKey(rand.Reader)
737+
if err != nil {
738+
t.Fatalf("x25519 keygen: %v", err)
739+
}
740+
peerNodeID := uint32(0x50515253)
741+
742+
challenge := make([]byte, 4+4+32)
743+
copy(challenge[0:4], []byte("auth"))
744+
binary.BigEndian.PutUint32(challenge[4:8], peerNodeID)
745+
copy(challenge[8:40], peerX.PublicKey().Bytes())
746+
sig := ed25519.Sign(peerPriv, challenge)
747+
748+
tm.AddPeer(peerNodeID, mustUDPAddr(t, "127.0.0.1:9997"))
749+
750+
data := make([]byte, 132)
751+
binary.BigEndian.PutUint32(data[0:4], peerNodeID)
752+
copy(data[4:36], peerX.PublicKey().Bytes())
753+
copy(data[36:68], peerPub)
754+
copy(data[68:132], sig)
755+
from := mustUDPAddr(t, "127.0.0.1:1234")
756+
return tm, peerNodeID, data, from
757+
}

0 commit comments

Comments
 (0)