Skip to content

Commit b1ecb8a

Browse files
committed
fix(daemon): guard duplicate rekey initiation, fix blackhole detection for new peers
- tunnel.go: skip sendKeyExchangeToNode if already pending to prevent rapid SYN retransmits from exhausting MaxRekeyAttempts before relay path stabilises; reset Attempts counter when routing path flips to relay - keyexchange: add ResetPendingRekeyAttempts for path-change resets - routing: track firstOutboundSend as blackhole baseline for brand-new peers that have never replied on the direct path (symmetric NAT case) - beacon: raise maxBeaconNodes 100k→500k after network exceeded threshold
1 parent 4b70629 commit b1ecb8a

6 files changed

Lines changed: 70 additions & 12 deletions

File tree

pkg/beacon/server.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ const relayQueueSize = 131072 // 128K buffered relay jobs before backpressure
6666
const maxRelayPayload = 65535
6767

6868
// maxBeaconNodes caps the number of tracked nodes to prevent memory exhaustion.
69-
const maxBeaconNodes = 100_000
69+
// At ~32 bytes per node, 500k entries consume ~16 MB — well within fleet budget.
70+
// Raised from 100k after the network grew past that threshold (v1.9.4).
71+
const maxBeaconNodes = 500_000
7072

7173
// beaconNodeTTL is how long a node entry lives without a discover refresh.
7274
// Set to 10 minutes (well above the 60s heartbeat-driven re-discover interval)

pkg/daemon/keyexchange/keyexchange.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,20 @@ func (m *Manager) ClearPendingRekey(peerNodeID uint32) {
365365
m.rkPendingMu.Unlock()
366366
}
367367

368+
// ResetPendingRekeyAttempts zeroes the Attempts counter for peerNodeID
369+
// without cancelling the pending-rekey entry. Called when the routing
370+
// path changes (e.g. direct→relay flip) so the peer gets a fresh set
371+
// of retransmit slots on the new path rather than immediately hitting
372+
// the MaxRekeyAttempts give-up threshold from prior direct attempts.
373+
func (m *Manager) ResetPendingRekeyAttempts(peerNodeID uint32) {
374+
m.rkPendingMu.Lock()
375+
if st, ok := m.pendingRekey[peerNodeID]; ok {
376+
st.Attempts = 0
377+
st.LastSentAt = time.Time{} // force immediate retransmit on next tick
378+
}
379+
m.rkPendingMu.Unlock()
380+
}
381+
368382
// ClearRekeyGaveUp lifts the post-give-up cooldown for peerNodeID. Must only
369383
// be called after a successful decrypt — proof that bidirectional crypto works.
370384
func (m *Manager) ClearRekeyGaveUp(peerNodeID uint32) {

pkg/daemon/routing/blackhole.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import (
1010

1111
// RecordDirectRecv stamps the lastDirectRecv timestamp for a peer. Called
1212
// from the L7 handleEncrypted path after a successful direct (non-beacon)
13-
// decrypt.
13+
// decrypt. Also clears firstOutboundSend since the direct path is confirmed.
1414
func (m *Manager) RecordDirectRecv(nodeID uint32, t time.Time) {
1515
m.mu.Lock()
1616
m.lastDirectRecv[nodeID] = t
17+
delete(m.firstOutboundSend, nodeID)
1718
m.mu.Unlock()
1819
}
1920

@@ -34,10 +35,16 @@ func (m *Manager) LastDirectRecv(nodeID uint32) time.Time {
3435
}
3536

3637
// RecordOutboundSend stamps the lastOutboundSend timestamp for a peer.
37-
// Used by NAT-keepalive logic.
38+
// Also sets firstOutboundSend on the very first write (never updated
39+
// after that), which gives MaybeFlipBlackhole a baseline for peers
40+
// that have never been heard from on the direct path.
41+
// Used by NAT-keepalive logic and blackhole detection.
3842
func (m *Manager) RecordOutboundSend(nodeID uint32, t time.Time) {
3943
m.mu.Lock()
4044
m.lastOutboundSend[nodeID] = t
45+
if _, ok := m.firstOutboundSend[nodeID]; !ok {
46+
m.firstOutboundSend[nodeID] = t
47+
}
4148
m.mu.Unlock()
4249
}
4350

@@ -105,15 +112,28 @@ func (m *Manager) MaybeFlipBlackhole(nodeID uint32) (shouldRelay, flipped bool,
105112
relay := m.relayPeers[nodeID]
106113
bAddr := m.beaconAddr
107114
lastRecv, hasRecv := m.lastDirectRecv[nodeID]
115+
firstSend, hasSent := m.firstOutboundSend[nodeID]
108116
m.mu.RUnlock()
109117

110118
if relay {
111119
return true, false, 0, 0
112120
}
113-
if bAddr == nil || !hasRecv {
121+
if bAddr == nil {
122+
return false, false, 0, 0
123+
}
124+
if hasRecv {
125+
// Established peer: measure silence since last direct receipt.
126+
silentFor = time.Since(lastRecv)
127+
} else if hasSent {
128+
// Brand-new peer (no direct recv yet): measure silence since
129+
// we first sent to them. This lets the blackhole heuristic fire
130+
// for new peers on symmetric-NAT paths where the direct reply
131+
// can never reach us — without this, !hasRecv would suppress
132+
// blackhole detection forever and the peer would never flip to relay.
133+
silentFor = time.Since(firstSend)
134+
} else {
114135
return false, false, 0, 0
115136
}
116-
silentFor = time.Since(lastRecv)
117137
if silentFor <= DirectBlackholeThreshold {
118138
return false, false, 0, 0
119139
}

pkg/daemon/routing/relay.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func (m *Manager) RemovePeer(nodeID uint32) {
151151
delete(m.relayPeers, nodeID)
152152
delete(m.relayPinned, nodeID)
153153
delete(m.lastOutboundSend, nodeID)
154+
delete(m.firstOutboundSend, nodeID)
154155
delete(m.sendErrCount, nodeID)
155156
delete(m.lastDirectRecv, nodeID)
156157
delete(m.blackholeMissCount, nodeID)

pkg/daemon/routing/routing.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// - sendErrCount map[uint32]int — consecutive ICMP-unreachable errors
1111
// - lastDirectRecv map[uint32]time.Time — last direct-path receipt
1212
// - lastOutboundSend map[uint32]time.Time — last successful send
13+
// - firstOutboundSend map[uint32]time.Time — first ever send (blackhole baseline)
1314
// - beaconAddr — the picked beacon for relay/punch
1415
//
1516
// Per docs/architecture/01-LAYERS.md L4:
@@ -123,6 +124,12 @@ type Manager struct {
123124
// to each peer. Used by NAT-keepalive logic.
124125
lastOutboundSend map[uint32]time.Time
125126

127+
// firstOutboundSend tracks when we FIRST sent to each peer. Unlike
128+
// lastOutboundSend it is never updated after the initial write.
129+
// Used by MaybeFlipBlackhole as the blackhole-detection baseline
130+
// for brand-new peers that have no lastDirectRecv entry yet.
131+
firstOutboundSend map[uint32]time.Time
132+
126133
// localNodeIDFn supplies our own node ID for relay-wrapping headers.
127134
localNodeIDFn LocalNodeIDFn
128135
}
@@ -138,6 +145,7 @@ func New() *Manager {
138145
directClearCount: make(map[uint32]int),
139146
sendErrCount: make(map[uint32]int),
140147
lastOutboundSend: make(map[uint32]time.Time),
148+
firstOutboundSend: make(map[uint32]time.Time),
141149
}
142150
}
143151

pkg/daemon/tunnel.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,11 @@ func (tm *TunnelManager) writeFrame(nodeID uint32, addr *net.UDPAddr, frame []by
467467
"peer_node_id", nodeID,
468468
"error", err)
469469
}
470+
// Reset the key-exchange attempt counter so the peer gets a fresh
471+
// set of relay-based retransmit slots. Without this, Attempts has
472+
// already reached MaxRekeyAttempts+1 from the failed direct attempts
473+
// and the next tick immediately gives up before any relay frame lands.
474+
tm.kx.ResetPendingRekeyAttempts(nodeID)
470475
}
471476

472477
if errors.Is(err, routing.ErrNoAddress) {
@@ -1278,11 +1283,15 @@ func (tm *TunnelManager) SendTo(addr *net.UDPAddr, nodeID uint32, pkt *protocol.
12781283
}
12791284

12801285
// No key yet — initiate key exchange and queue the packet (C1 fix: no plaintext fallback).
1281-
// Skip if the peer is within the give-up cooldown: the key exchange
1282-
// will still be sent (one-shot, no retransmit) but queuing more data
1283-
// just fills the pending queue with undeliverable packets and floods
1284-
// the logs with "dropped oldest" warnings.
1285-
tm.sendKeyExchangeToNode(nodeID)
1286+
// Only trigger a new key exchange if one isn't already in-flight; the
1287+
// retransmit loop handles subsequent retries. Without this guard, rapid
1288+
// outbound traffic (e.g. SYN retransmits every 250ms during dial) calls
1289+
// sendKeyExchangeToNode repeatedly, each incrementing Attempts until it
1290+
// reaches MaxRekeyAttempts+1 within seconds and the peer gives up before
1291+
// the relay path is even established.
1292+
if !tm.kx.PendingRekeyHas(nodeID) {
1293+
tm.sendKeyExchangeToNode(nodeID)
1294+
}
12861295
if tm.kx.PeerInRekeyGaveUp(nodeID) {
12871296
return fmt.Errorf("%w (peer_node_id=%d)", ErrPendingDropped, nodeID)
12881297
}
@@ -1337,8 +1346,12 @@ func (tm *TunnelManager) AddPeer(nodeID uint32, addr *net.UDPAddr) {
13371346
tm.mu.Unlock()
13381347
slog.Debug("added peer", "node_id", nodeID, "addr", addr)
13391348

1340-
// If encryption is enabled, initiate key exchange (relay-aware)
1341-
if tm.encrypt {
1349+
// If encryption is enabled, initiate key exchange (relay-aware).
1350+
// Guard matches writeToNode's PendingRekeyHas check: concurrent AddPeer
1351+
// calls for the same node (e.g. two parallel dial goroutines) must not
1352+
// each increment Attempts, which would exhaust MaxRekeyAttempts before
1353+
// the retransmit loop can pace retries at RekeyRetransmitInterval.
1354+
if tm.encrypt && !tm.kx.PendingRekeyHas(nodeID) {
13421355
tm.sendKeyExchangeToNode(nodeID)
13431356
}
13441357
}

0 commit comments

Comments
 (0)