Skip to content

Commit d14e440

Browse files
committed
fix(cluster): single global sender counter and self-addressed replay guard
Replace per-target outbound seq counters in `PeerSeqSender` with a single monotonic counter. The receiver's replay window is keyed by the sender's `local_node_id`, so per-target counters from the same sender collide in that window: seq=1 sent to A and seq=1 sent to B are indistinguishable to any node that receives from this sender. A single counter makes every outbound frame globally unique from the receiver's perspective. Also skip the inbound replay-window check when `from_node_id` matches the local node. In single-node tests and genuine self-dispatch, the client and server share one `AuthContext`, so one `peer_seq_in` window is touched by both the server-side request accept and the client-side response accept. Without this guard, the second accept trips on the first — the frame was never replayed; the window simply saw both directions on the same entry. Update `transport_security.rs` to use `tokio::sync::Mutex` for the insecure-counter serialization lock so the guard can be held across `.await` points without tripping `clippy::await_holding_lock`.
1 parent c09e2fa commit d14e440

5 files changed

Lines changed: 74 additions & 58 deletions

File tree

nodedb-cluster/src/rpc_codec/peer_seq.rs

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -32,42 +32,33 @@ use crate::error::{ClusterError, Result};
3232
/// Size of the inbound replay-detection window.
3333
pub const REPLAY_WINDOW: u64 = 64;
3434

35-
/// Per-peer outbound monotonic counter. One counter per (local_node,
36-
/// remote_peer) pair. Thread-safe via atomics.
35+
/// Outbound monotonic counter for this `AuthContext`. One counter total
36+
/// — not one per target — because the receiver's replay window is keyed
37+
/// by the *sender's* `local_node_id`. If this sender used a per-target
38+
/// counter, two distinct targets' traffic would share the same window on
39+
/// any node that receives from both: seq=1 from target=A and seq=1 from
40+
/// target=B collide in the receiver's `window[sender_id]`. A single
41+
/// counter makes every outbound seq globally unique per sender.
3742
#[derive(Default, Debug)]
3843
pub struct PeerSeqSender {
39-
counters: RwLock<HashMap<u64, AtomicU64>>,
44+
counter: AtomicU64,
4045
}
4146

4247
impl PeerSeqSender {
4348
pub fn new() -> Self {
4449
Self::default()
4550
}
4651

47-
/// Reserve and return the next sequence number for frames sent to
48-
/// `peer_id`. Sequence starts at 1 and is strictly increasing.
49-
pub fn next(&self, peer_id: u64) -> u64 {
50-
// Fast path: counter already exists.
51-
{
52-
let guard = self.counters.read().unwrap_or_else(|p| p.into_inner());
53-
if let Some(counter) = guard.get(&peer_id) {
54-
return counter.fetch_add(1, Ordering::Relaxed) + 1;
55-
}
56-
}
57-
// Slow path: create counter under the write lock.
58-
let mut guard = self.counters.write().unwrap_or_else(|p| p.into_inner());
59-
let counter = guard.entry(peer_id).or_insert_with(|| AtomicU64::new(0));
60-
counter.fetch_add(1, Ordering::Relaxed) + 1
52+
/// Reserve and return the next outbound sequence number. Starts at 1
53+
/// and is strictly increasing across all targets for this sender.
54+
pub fn next(&self) -> u64 {
55+
self.counter.fetch_add(1, Ordering::Relaxed) + 1
6156
}
6257

6358
/// Current counter value (0 if no frames have been sent). Test-only.
6459
#[cfg(test)]
65-
pub fn peek(&self, peer_id: u64) -> u64 {
66-
let guard = self.counters.read().unwrap_or_else(|p| p.into_inner());
67-
guard
68-
.get(&peer_id)
69-
.map(|c| c.load(Ordering::Relaxed))
70-
.unwrap_or(0)
60+
pub fn peek(&self) -> u64 {
61+
self.counter.load(Ordering::Relaxed)
7162
}
7263
}
7364

@@ -157,18 +148,24 @@ mod tests {
157148
#[test]
158149
fn outbound_counter_starts_at_one() {
159150
let s = PeerSeqSender::new();
160-
assert_eq!(s.next(1), 1);
161-
assert_eq!(s.next(1), 2);
162-
assert_eq!(s.next(1), 3);
151+
assert_eq!(s.next(), 1);
152+
assert_eq!(s.next(), 2);
153+
assert_eq!(s.next(), 3);
163154
}
164155

165156
#[test]
166-
fn outbound_counters_are_independent_per_peer() {
157+
fn outbound_counter_is_single_across_all_targets() {
158+
// The outbound counter is intentionally shared across targets: the
159+
// receiver's replay window is keyed by the sender's local_node_id,
160+
// so per-target counters would collide in the same window. A
161+
// single monotonic counter guarantees every emitted seq is unique
162+
// from the receiver's point of view regardless of which target
163+
// the sender was aiming at.
167164
let s = PeerSeqSender::new();
168-
assert_eq!(s.next(1), 1);
169-
assert_eq!(s.next(2), 1);
170-
assert_eq!(s.next(1), 2);
171-
assert_eq!(s.next(2), 2);
165+
assert_eq!(s.next(), 1);
166+
assert_eq!(s.next(), 2);
167+
assert_eq!(s.next(), 3);
168+
assert_eq!(s.next(), 4);
172169
}
173170

174171
#[test]

nodedb-cluster/src/swim/wire/authenticated.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,13 @@ impl SwimAuth {
9494

9595
/// Encode `msg` and wrap it in an authenticated envelope destined for
9696
/// `to`. Returns the bytes to hand to `UdpSocket::send_to`.
97-
pub fn wrap(auth: &SwimAuth, to: SocketAddr, msg: &SwimMessage) -> Result<Vec<u8>, SwimError> {
97+
///
98+
/// `to` is retained in the signature as documentation (callers pass the
99+
/// destination they are about to `send_to`), but the outbound seq is a
100+
/// single sender-global counter — see `PeerSeqSender`.
101+
pub fn wrap(auth: &SwimAuth, _to: SocketAddr, msg: &SwimMessage) -> Result<Vec<u8>, SwimError> {
98102
let inner = codec::encode(msg)?;
99-
let to_hash = addr_hash(to);
100-
let seq = auth.seq_out.next(to_hash);
103+
let seq = auth.seq_out.next();
101104
let mut out = Vec::with_capacity(auth_envelope::ENVELOPE_OVERHEAD + inner.len());
102105
auth_envelope::write_envelope(auth.local_addr_hash, seq, &inner, &auth.mac_key, &mut out)
103106
.map_err(|e| SwimError::Encode {

nodedb-cluster/src/transport/client/send.rs

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,6 @@ use crate::transport::server;
1919

2020
use super::transport::NexarTransport;
2121

22-
/// Sentinel peer id for the outbound seq counter when the real peer id is
23-
/// not yet known (bootstrap/join — see [`send_rpc_to_addr`]).
24-
///
25-
/// Using a fixed key means two concurrent bootstrap attempts on the same
26-
/// transport share a counter, which is fine — each attempt produces a
27-
/// strictly-higher seq than the last, and neither attempt cares about
28-
/// pairing its outbound seq with any specific remote id.
29-
const BOOTSTRAP_PEER_ID: u64 = 0;
30-
3122
impl NexarTransport {
3223
/// Send an RPC to an address directly (for bootstrap/join before peer
3324
/// IDs are known).
@@ -45,7 +36,7 @@ impl NexarTransport {
4536
}
4637

4738
async fn send_rpc_to_addr_inner(&self, addr: SocketAddr, rpc: RaftRpc) -> Result<RaftRpc> {
48-
let envelope = self.wrap_outbound(BOOTSTRAP_PEER_ID, &rpc)?;
39+
let envelope = self.wrap_outbound(&rpc)?;
4940

5041
let conn = self
5142
.listener
@@ -94,7 +85,7 @@ impl NexarTransport {
9485
tokio::time::sleep(delay).await;
9586
}
9687

97-
let envelope = self.wrap_inner(target, &inner)?;
88+
let envelope = self.wrap_inner(&inner)?;
9889
match self.try_send_once(target, &envelope).await {
9990
Ok(resp) => {
10091
self.circuit_breaker.record_success(target);
@@ -156,15 +147,15 @@ impl NexarTransport {
156147
Ok(self.parse_inbound(&response_envelope))
157148
}
158149

159-
/// Encode and wrap an RPC for a known peer id.
160-
fn wrap_outbound(&self, target: u64, rpc: &RaftRpc) -> Result<Vec<u8>> {
150+
/// Encode and wrap an RPC in an authenticated envelope.
151+
fn wrap_outbound(&self, rpc: &RaftRpc) -> Result<Vec<u8>> {
161152
let inner = rpc_codec::encode(rpc)?;
162-
self.wrap_inner(target, &inner)
153+
self.wrap_inner(&inner)
163154
}
164155

165156
/// Wrap an already-encoded inner frame in an authenticated envelope.
166-
fn wrap_inner(&self, target: u64, inner: &[u8]) -> Result<Vec<u8>> {
167-
let seq = self.auth.peer_seq_out.next(target);
157+
fn wrap_inner(&self, inner: &[u8]) -> Result<Vec<u8>> {
158+
let seq = self.auth.peer_seq_out.next();
168159
let mut out = Vec::with_capacity(auth_envelope::ENVELOPE_OVERHEAD + inner.len());
169160
auth_envelope::write_envelope(
170161
self.auth.local_node_id,
@@ -178,11 +169,22 @@ impl NexarTransport {
178169

179170
/// Parse an inbound envelope: verify MAC, check replay window, decode
180171
/// inner RPC.
172+
///
173+
/// Self-addressed frames skip the replay-window check. In a single-node
174+
/// test (or when a node genuinely dispatches an RPC to itself over the
175+
/// transport) the client and server share one `AuthContext`, which
176+
/// means one `peer_seq_in` window is updated by *both* the server-side
177+
/// request-accept and the client-side response-accept. Without this
178+
/// guard the second accept trips on its own first — the envelope
179+
/// was never replayed, the same window simply saw traffic from both
180+
/// directions for `peer_id == local_node_id`.
181181
fn parse_inbound(&self, envelope: &[u8]) -> Result<RaftRpc> {
182182
let (fields, inner_frame) = auth_envelope::parse_envelope(envelope, &self.auth.mac_key)?;
183-
self.auth
184-
.peer_seq_in
185-
.accept(fields.from_node_id, fields.seq)?;
183+
if fields.from_node_id != self.auth.local_node_id {
184+
self.auth
185+
.peer_seq_in
186+
.accept(fields.from_node_id, fields.seq)?;
187+
}
186188
rpc_codec::decode(inner_frame)
187189
}
188190
}

nodedb-cluster/src/transport/server.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,17 @@ async fn handle_stream<H: RaftRpcHandler>(
107107
let (fields, inner_frame) = auth_envelope::parse_envelope(&envelope, &auth.mac_key)?;
108108

109109
// 2. Replay window — under the advertised from_node_id (MAC-verified).
110-
auth.peer_seq_in.accept(fields.from_node_id, fields.seq)?;
110+
// Self-addressed frames skip the window: when a node dispatches
111+
// an RPC to itself over the transport, the shared `AuthContext`
112+
// means one window is updated by both the server-side request
113+
// accept (here) and the client-side response accept (in
114+
// `send.rs::parse_inbound`). Skipping when `from == local`
115+
// keeps the two flows from tripping on each other's entries —
116+
// a self-addressed frame can't have been replayed by an
117+
// external attacker by definition.
118+
if fields.from_node_id != auth.local_node_id {
119+
auth.peer_seq_in.accept(fields.from_node_id, fields.seq)?;
120+
}
111121

112122
// 3. Decode inner RPC and hand to handler.
113123
let request = rpc_codec::decode(inner_frame)?;
@@ -116,7 +126,7 @@ async fn handle_stream<H: RaftRpcHandler>(
116126
// 4. Wrap the response in its own envelope. `from = local_node_id`,
117127
// `seq = next outbound seq scoped to the caller`.
118128
let response_inner = rpc_codec::encode(&response)?;
119-
let response_seq = auth.peer_seq_out.next(fields.from_node_id);
129+
let response_seq = auth.peer_seq_out.next();
120130
let mut response_envelope =
121131
Vec::with_capacity(auth_envelope::ENVELOPE_OVERHEAD + response_inner.len());
122132
auth_envelope::write_envelope(

nodedb-cluster/tests/transport_security.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@
2020
//! Tests build transports on ephemeral loopback ports and do not depend
2121
//! on any cluster infrastructure beyond the transport layer itself.
2222
23-
use std::sync::{Arc, Mutex, OnceLock};
23+
use std::sync::{Arc, OnceLock};
2424
use std::time::Duration;
25+
use tokio::sync::Mutex;
2526

2627
/// Serializes tests that construct `TransportCredentials::Insecure`, so the
2728
/// process-global `insecure_transport_count()` observed by
2829
/// `observability_insecure_counter_monotonic` is not racing with other
2930
/// concurrent tokio tests that also bump the counter.
31+
///
32+
/// Uses `tokio::sync::Mutex` so the guard can be held across `.await`
33+
/// points without tripping `clippy::await_holding_lock`.
3034
fn insecure_counter_guard() -> &'static Mutex<()> {
3135
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3236
LOCK.get_or_init(|| Mutex::new(()))
@@ -193,7 +197,7 @@ async fn l1_different_ca_mtls_rejects_handshake() {
193197
/// self-signed cert not signed by the client's CA).
194198
#[tokio::test]
195199
async fn l1_insecure_server_rejected_by_mtls_client() {
196-
let _guard = insecure_counter_guard().lock().unwrap();
200+
let _guard = insecure_counter_guard().lock().await;
197201
let (_ca, client_creds) = generate_node_credentials("nodedb").unwrap();
198202
let server = Arc::new(
199203
NexarTransport::new(
@@ -352,7 +356,7 @@ async fn l3_swim_rejects_mismatched_mac_key() {
352356
/// L.5 / observability: every `Insecure` construction bumps the counter.
353357
#[tokio::test]
354358
async fn observability_insecure_counter_monotonic() {
355-
let _guard = insecure_counter_guard().lock().unwrap();
359+
let _guard = insecure_counter_guard().lock().await;
356360
let before = insecure_transport_count();
357361
let _ = NexarTransport::new(
358362
99,

0 commit comments

Comments
 (0)