decdn_node/handlers/dht.rs
1//! `cdn/dht/v1` handler (ADR 022) — Kademlia content discovery.
2//!
3//! Request handling:
4//!
5//! - `FindNode` honored against the in-memory routing table.
6//! - `Store` admission runs the full ADR 022 pipeline: holder ==
7//! authenticated `NodeId`, active-staker filter, per-publisher quota
8//! (200), global LRU (100k), per-hash provider cap (50), receiver-
9//! anchored TTL (1 h).
10//! - `FindValue` consults the record store + the routing table; per
11//! ADR 022 §Lookup integrity the requester-side filters (XOR-distance,
12//! active-staker, negative-probe-cache, randomisation) are the
13//! requester's job and live in the iterative-lookup module
14//! ([`crate::dht::lookup`]).
15//! - `BatchStore` admission (ADR 022 §Batch token accounting, #648):
16//! one batch-level `holder == authenticated NodeId` check (mismatch
17//! closes the stream with `MALFORMED_MESSAGE`), two-stage rate-limit
18//! accounting (stage 1 charged the inbound frame in `serve`; stage 2
19//! charges up to `n-1` more, partially admitting the batch under
20//! budget), then per-hash insert sharing one receiver-anchored
21//! `receive_us`. The reply is a `BatchStoreAck` with one bool per
22//! request hash, in request order. An oversize batch
23//! (> `MAX_BATCH_STORE_HASHES`) is rejected at wire decode with
24//! `MALFORMED_MESSAGE`.
25//! - Response variants arriving on a server-accepted stream are
26//! rejected with `APP_ERR_UNSUPPORTED_MESSAGE`.
27
28use std::net::IpAddr;
29use std::sync::Arc;
30use std::sync::Mutex;
31use std::time::Duration;
32
33use decdn_protocol::{
34 ALPN_DHT, APP_ERR_RATE_LIMITED, FrameError, MAX_CLOSER_NODES, decode_message, dht as wire,
35 encode_message, is_unknown_variant, read_frame, write_frame,
36};
37use iroh::PublicKey;
38use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt};
39use iroh::protocol::{AcceptError, ProtocolHandler};
40
41use crate::dht::chain_projection::with_lock;
42use crate::dht::{
43 AuthenticatedNodeId, DhtRateLimiter, DhtRejectLayer, NodeId, RecordStore, RoutingTable,
44 StakerSet,
45};
46use crate::dispatch::{ConnectionLimiter, RejectReason};
47use crate::metrics::Metrics;
48
49const ACCEPT_BI_TIMEOUT: Duration = Duration::from_secs(5);
50const READ_TIMEOUT: Duration = Duration::from_secs(5);
51const CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
52const REJECTION_CLOSE_TIMEOUT: Duration = Duration::from_millis(250);
53
54/// Per-connection request cap (#845, ADR 022 §DHT Rate Limiting). The
55/// per-stream rate limiter throttles request *rate*, but a peer holding
56/// under the rate can still open streams forever and pin its dispatch
57/// permit (acquired once per connection in `serve`) for the whole process
58/// lifetime. Cap total served streams so the permit is recycled. Mirrors
59/// the hardcoded-constant precedent in `handlers::client` (`MAX_CLIENT_STREAMS`).
60const MAX_DHT_REQUESTS_PER_CONN: u32 = 256;
61/// Per-connection age deadline (#845, ADR 022 §DHT Rate Limiting). Bounds
62/// dispatch-permit hold time independent of the request count: a peer that
63/// dribbles requests just under the cap still releases its permit once the
64/// connection reaches this age. The serve loop stops accepting new streams
65/// past it; the existing post-loop `conn.closed()` flush then releases.
66const MAX_DHT_CONN_AGE: Duration = Duration::from_mins(5);
67
68/// Whether the per-connection accept loop has reached its budget (#845): the
69/// served-stream count cap or the connection-age deadline. Factored out of the
70/// `serve` loop so the unit test exercises the real gate the loop checks rather
71/// than a re-implementation of it.
72fn accept_budget_reached(served: u32, conn_age: Duration) -> bool {
73 served >= MAX_DHT_REQUESTS_PER_CONN || conn_age >= MAX_DHT_CONN_AGE
74}
75
76// ADR 013 application error codes (also defined in `handlers::probe`; the
77// codes are protocol-wide constants, not per-ALPN values).
78const APP_ERR_UNSUPPORTED_MESSAGE: u32 = 0x01;
79const APP_ERR_MESSAGE_TOO_LARGE: u32 = 0x02;
80const APP_ERR_MALFORMED_MESSAGE: u32 = 0x03;
81
82/// Serves `cdn/dht/v1`. Owns the routing table + the per-request rate
83/// limiter; `accept()` is reentrant — many concurrent streams from
84/// different peers all share the same `Arc<DhtHandler>`.
85///
86/// Routing-table updates take a short `Mutex` — the table is touched once
87/// per request to refresh the requester's recency and once again to
88/// produce the `closer_nodes` snapshot. We use `std::sync::Mutex` rather
89/// than `tokio::sync::Mutex` since the critical section is microseconds
90/// long (no awaits) and the lock would just add scheduler overhead.
91pub struct DhtHandler {
92 self_id: PublicKey,
93 routing: Arc<Mutex<RoutingTable>>,
94 /// Receiver-side record store (ADR 022 §Content Records and TTL).
95 /// `std::sync::Mutex` for the same reason as `routing`: the
96 /// critical section is microseconds long (one `HashMap` mutation +
97 /// two index updates) with no awaits.
98 records: Arc<Mutex<RecordStore>>,
99 /// Cached active-staker set consulted on every `Store` admission
100 /// (ADR 022 §STORE Flow line 140). `Arc<dyn StakerSet>` so the
101 /// handler is indifferent to the source: the daemon injects the
102 /// chain-backed projection of `CapacityBond`, tests inject an
103 /// in-memory set.
104 staker_set: Arc<dyn StakerSet>,
105 rate_limiter: Arc<DhtRateLimiter>,
106 dispatch_limiter: Arc<ConnectionLimiter>,
107 metrics: Arc<Metrics>,
108}
109
110impl std::fmt::Debug for DhtHandler {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("DhtHandler")
113 .field("self_id", &self.self_id)
114 .finish_non_exhaustive()
115 }
116}
117
118impl DhtHandler {
119 /// The ALPN this handler answers on: `cdn/dht/v1`, the content-discovery
120 /// protocol (ADR 022).
121 pub const ALPN: &'static [u8] = ALPN_DHT;
122
123 /// Construct a handler. The routing table is empty at construction; the
124 /// runtime's DHT bootstrap fills it from the on-chain staker set, and the
125 /// handler additionally updates the table from every incoming
126 /// request's authenticated `NodeId` once it knows the peer is
127 /// rate-limit admitted.
128 #[must_use]
129 pub fn new(
130 self_id: PublicKey,
131 rate_limiter: Arc<DhtRateLimiter>,
132 dispatch_limiter: Arc<ConnectionLimiter>,
133 metrics: Arc<Metrics>,
134 staker_set: Arc<dyn StakerSet>,
135 records: Arc<Mutex<RecordStore>>,
136 ) -> Self {
137 let routing = Arc::new(Mutex::new(RoutingTable::new(NodeId::from_bytes(
138 *self_id.as_bytes(),
139 ))));
140 Self {
141 self_id,
142 routing,
143 records,
144 staker_set,
145 rate_limiter,
146 dispatch_limiter,
147 metrics,
148 }
149 }
150
151 /// Construct with a pre-built routing table. Used by tests and by the
152 /// bootstrap path that wants to seed the table before the handler
153 /// goes live.
154 #[must_use]
155 #[allow(clippy::too_many_arguments)] // wiring struct; each arg is distinct runtime state.
156 pub const fn with_routing(
157 self_id: PublicKey,
158 routing: Arc<Mutex<RoutingTable>>,
159 rate_limiter: Arc<DhtRateLimiter>,
160 dispatch_limiter: Arc<ConnectionLimiter>,
161 metrics: Arc<Metrics>,
162 staker_set: Arc<dyn StakerSet>,
163 records: Arc<Mutex<RecordStore>>,
164 ) -> Self {
165 Self {
166 self_id,
167 routing,
168 records,
169 staker_set,
170 rate_limiter,
171 dispatch_limiter,
172 metrics,
173 }
174 }
175
176 /// Shared handle to the routing table — exposed so the runtime's bootstrap
177 /// and bucket-refresh tasks drive the same table without re-allocating the
178 /// `Arc<Mutex<RoutingTable>>` from outside.
179 #[must_use]
180 pub fn routing_table(&self) -> Arc<Mutex<RoutingTable>> {
181 Arc::clone(&self.routing)
182 }
183
184 // Linear protocol sequence — same rationale as `ProbeHandler::serve`
185 // for keeping the ADR 013 error-code mapping and the ADR 022 admission
186 // order in one auditable function.
187 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
188 async fn serve(&self, conn: Connection) -> anyhow::Result<()> {
189 // Layer 1: dispatch limiter (connection-level cap).
190 let _permit = match self.dispatch_limiter.acquire(&conn) {
191 Ok(p) => p,
192 Err(reason) => {
193 conn.close(
194 VarInt::from_u32(APP_ERR_RATE_LIMITED),
195 reason.as_str().as_bytes(),
196 );
197 if reason != RejectReason::GlobalFull {
198 let _ = tokio::time::timeout(REJECTION_CLOSE_TIMEOUT, conn.closed()).await;
199 }
200 return Ok(());
201 }
202 };
203 let _guard = self.metrics.connection_guard();
204
205 // Resolve the peer's NodeId once (it's the authenticated QUIC
206 // identity and cannot change for the lifetime of the connection).
207 // The IP, by contrast, is re-evaluated per stream below — iroh
208 // can switch the selected path mid-connection (e.g. relay → direct
209 // UDP after hole-punching) and caching the initial value would
210 // permanently disable the per-IP rate-limit layer for any
211 // connection that started relay-only.
212 // Lift the handshake-bound identity into the trust boundary. From here
213 // on the only `NodeId` the handler treats as "who is actually on the
214 // other end" flows through this `AuthenticatedNodeId`; wire-provided
215 // ids (`holder`, `requester`) are a distinct type and cannot reach the
216 // routing-table-insert path without an explicit `.node_id()`.
217 let peer = AuthenticatedNodeId::from_connection(&conn);
218
219 // ADR 022 §DHT Rate Limiting check fires before any deserialization
220 // of the per-stream frame body — we do it per stream below since
221 // each DHT request is one stream. The check on the *connection*
222 // here would be redundant; we run it inside the per-stream loop
223 // because a single connection may carry multiple requests.
224
225 // Iroh-side connection cap: DHT is short-lived request/response,
226 // but a peer may bundle multiple requests on one connection. Accept
227 // streams in a loop until the peer closes (or we time out waiting).
228 //
229 // Permit hygiene (#845): the dispatch permit above is held for the
230 // whole `serve` call, so an unbounded accept loop lets a peer staying
231 // under the per-stream rate limit pin a permit indefinitely. Bound
232 // both the total served streams (`MAX_DHT_REQUESTS_PER_CONN`) and the
233 // connection age (`MAX_DHT_CONN_AGE`) so the permit is always
234 // recycled; the loop is sequential (`handle_one` is awaited, not
235 // spawned), so a count + age cap — not a concurrency semaphore — is
236 // the right tool.
237 let conn_start = std::time::Instant::now();
238 let mut served: u32 = 0;
239 loop {
240 // Stop accepting once this connection has consumed its per-conn
241 // request budget or outlived the age deadline. Checked at the top
242 // so the previously-accepted stream is always served in full.
243 // Unlike the peer-close / idle-timeout break below (which the peer
244 // initiated), this is a resource-exhaustion close *we* initiate, so
245 // — mirroring `close_stream_with_rate_limit` and the client
246 // handler's permit-exhaustion close — signal the peer with
247 // `APP_ERR_RATE_LIMITED` and log which bound tripped rather than
248 // dropping further requests silently; the post-loop `conn.closed()`
249 // flush then returns and releases the dispatch permit.
250 if accept_budget_reached(served, conn_start.elapsed()) {
251 tracing::debug!(
252 served,
253 age_secs = conn_start.elapsed().as_secs(),
254 "dht per-connection budget reached; closing to recycle the dispatch permit (#845)"
255 );
256 conn.close(
257 VarInt::from_u32(APP_ERR_RATE_LIMITED),
258 b"dht per-connection budget",
259 );
260 break;
261 }
262 // Peer closed the connection (`Ok(Err)`) or we hit the idle
263 // accept timeout (`Err`) — both mean we're done serving this
264 // connection; break the per-stream loop.
265 let Ok(Ok((send, recv))) =
266 tokio::time::timeout(ACCEPT_BI_TIMEOUT, conn.accept_bi()).await
267 else {
268 break;
269 };
270 served = served.saturating_add(1);
271 // Rate-limit per request (ADR 022 §DHT Rate Limiting). Re-read
272 // `peer_ip` here so a relay→direct path switch over the
273 // connection's lifetime engages the per-IP layer for
274 // subsequent streams.
275 let peer_ip = crate::rate_limit::peer_ip(&conn);
276 if let Err(layer) = self.rate_limiter.check(&peer.node_id(), peer_ip) {
277 Self::close_stream_with_rate_limit(send, recv, layer);
278 continue;
279 }
280 // Refresh routing-table entry — the requester just authenticated
281 // their NodeId via QUIC and successfully spent a rate-limit
282 // token. That's the same admission criteria ADR 022 §Routing
283 // Table demands for table insertion.
284 self.note_peer_seen(peer);
285 if let Err(e) = self.handle_one(peer, peer_ip, send, recv).await {
286 // The per-request error already carries the ADR 013 app
287 // error code via the stream reset inside `handle_one` /
288 // `read_dht_request`; here we surface the failure to
289 // operators via a debug log AND a counter bump so a node
290 // running at default `RUST_LOG=info` still reflects "DHT
291 // requests are failing" in scrapes without needing log
292 // verbosity changes.
293 self.metrics.dht_request_failed();
294 tracing::debug!(error = %e, "dht request handling failed");
295 // Drop this stream and continue accepting; the peer may
296 // succeed on the next request.
297 }
298 }
299 // Best-effort flush of any in-flight stream-finish bytes.
300 let _ = tokio::time::timeout(CLOSE_TIMEOUT, conn.closed()).await;
301 Ok(())
302 }
303
304 // No `self` needed — kept as an inherent fn so the rate-limit close
305 // path lives next to the only call site (the per-stream loop in
306 // `serve`).
307 fn close_stream_with_rate_limit(
308 mut send: SendStream,
309 mut recv: RecvStream,
310 layer: DhtRejectLayer,
311 ) {
312 let code = VarInt::from_u32(APP_ERR_RATE_LIMITED);
313 let _ = send.reset(code);
314 let _ = recv.stop(code);
315 tracing::debug!(
316 layer = layer.as_str(),
317 "dht request rejected by rate limiter",
318 );
319 }
320
321 /// Refresh the peer in the routing table. Takes an
322 /// [`AuthenticatedNodeId`] — only the QUIC-bound identity may seed the
323 /// table, never a wire-provided `requester`/`holder` (the
324 /// routing-table-poisoning guard, ADR 022 §Lookup integrity). Silently
325 /// ignores the node's own id (the table itself enforces that, but
326 /// skipping the lock-acquire is cheap).
327 fn note_peer_seen(&self, peer: AuthenticatedNodeId) {
328 if peer.node_id() == NodeId::from_bytes(*self.self_id.as_bytes()) {
329 return;
330 }
331 // Poison-tolerant like every other acquisition of this table
332 // (`with_lock` clears the poison as it recovers, so the warn is one
333 // line per panic, not one per admitted request). A silent skip here
334 // would drop every learned peer for the process lifetime after a
335 // single panic elsewhere.
336 with_lock(&self.routing, "dht routing table", |table| {
337 table.insert(peer.node_id());
338 });
339 }
340
341 /// Read one framed `DhtMessage`, dispatch on variant, write the
342 /// response frame back. Each stream carries exactly one
343 /// request/response pair. The authenticated `peer_node_id` is
344 /// threaded in so `Store` / `BatchStore` admission can compare the
345 /// wire-level `holder` against the QUIC-bound identity; `peer_ip` is
346 /// threaded in for the `BatchStore` stage-2 rate-limit accounting
347 /// (the per-IP layer keys on it).
348 async fn handle_one(
349 &self,
350 peer: AuthenticatedNodeId,
351 peer_ip: Option<IpAddr>,
352 mut send: SendStream,
353 mut recv: RecvStream,
354 ) -> anyhow::Result<()> {
355 let req = match read_dht_request(&mut send, &mut recv).await {
356 Ok(req) => req,
357 Err(DhtReadError { err, app_code }) => {
358 let _ = send.reset(VarInt::from_u32(app_code));
359 let _ = recv.stop(VarInt::from_u32(app_code));
360 return Err(err);
361 }
362 };
363
364 // Most variants produce a response frame. `BatchStore` may
365 // instead reject the whole batch with a stream-close-without-ack
366 // (holder mismatch → `MALFORMED_MESSAGE`, ADR 022 §Batch token
367 // accounting step 2) — `dispatch` signals that with `Err(code)`.
368 // That is a *deliberate* protocol rejection already counted by its
369 // specific counter (`dht_store_rejected_holder_mismatch`), so we
370 // close the stream and return `Ok(())`: returning `Err` would make
371 // `serve` bump `dht_requests_failed`, which is reserved for
372 // post-admission internal failures (decode/write/timeout), not
373 // intentional rejections.
374 let resp = match self.dispatch(peer, peer_ip, req) {
375 Ok(resp) => resp,
376 Err(app_code) => {
377 let _ = send.reset(VarInt::from_u32(app_code));
378 let _ = recv.stop(VarInt::from_u32(app_code));
379 tracing::debug!(app_code, "dht request rejected (stream-close-without-ack)");
380 return Ok(());
381 }
382 };
383 // `FindValueResponse` carries a Tier-1 extension (ADR 013 §Tier 1), so it
384 // is encoded two-phase; every other variant is a single postcard value.
385 let payload = match &resp {
386 wire::DhtMessage::FindValueResponse(r) => {
387 wire::encode_find_value_response(r, Some(&wire::FindValueResponseExt::default()))
388 .map_err(|e| anyhow::anyhow!("dht response encode failed: {e}"))?
389 }
390 other => encode_message(other)
391 .map_err(|e| anyhow::anyhow!("dht response encode failed: {e}"))?,
392 };
393 write_frame(&mut send, &payload)
394 .await
395 .map_err(|e| anyhow::anyhow!("dht response write failed: {e}"))?;
396 send.finish()
397 .map_err(|e| anyhow::anyhow!("dht stream finish failed: {e}"))?;
398 Ok(())
399 }
400
401 /// Convert a decoded request into a response. The argument's enum
402 /// shape encodes the precondition that this is one of the request
403 /// variants the handler implements — `read_dht_request` filters out
404 /// responses-on-server-stream before producing a
405 /// [`DhtServerRequest`], so this match is exhaustive over the request
406 /// set without needing a panic or echo fallback.
407 ///
408 /// Returns `Err(app_code)` when the variant must be answered with a
409 /// stream-close-without-ack rather than a response frame — the only
410 /// such case today is a `BatchStore` whose batch-level `holder` does
411 /// not match the authenticated `NodeId` (ADR 022 §Batch token
412 /// accounting step 2: reject the whole batch with `MALFORMED_MESSAGE`).
413 //
414 // `msg` is moved-in so the `BatchStore` arm can take ownership of
415 // `BatchStoreRequest::hashes` without re-allocating. The `Copy`
416 // request variants are a no-op at the ABI level under the by-value
417 // signature.
418 #[allow(clippy::needless_pass_by_value)]
419 fn dispatch(
420 &self,
421 peer: AuthenticatedNodeId,
422 peer_ip: Option<IpAddr>,
423 msg: DhtServerRequest,
424 ) -> Result<wire::DhtMessage, u32> {
425 Ok(match msg {
426 DhtServerRequest::FindNode(req) => {
427 wire::DhtMessage::FindNodeResponse(self.handle_find_node(req))
428 }
429 DhtServerRequest::FindValue(req) => {
430 wire::DhtMessage::FindValueResponse(self.handle_find_value(req))
431 }
432 DhtServerRequest::Store(req) => {
433 wire::DhtMessage::StoreAck(self.handle_store(peer, req))
434 }
435 DhtServerRequest::BatchStore(req) => {
436 wire::DhtMessage::BatchStoreAck(self.handle_batch_store(peer, peer_ip, req)?)
437 }
438 })
439 }
440
441 /// `BatchStore` admission (ADR 022 §STORE Flow Batched STORE + §Batch
442 /// token accounting, #648). A single batch-level decision followed by
443 /// per-hash admission identical to `n` separate `Store`s:
444 ///
445 /// 1. **Holder check (once).** `holder == authenticated NodeId`. On
446 /// mismatch the whole batch is rejected with `MALFORMED_MESSAGE`
447 /// (`Err(0x03)`) — a stream-close-without-ack — because a batch
448 /// claiming an identity the connection can't prove is malformed
449 /// (this is stricter than the per-hash `Store` path, which acks
450 /// `accepted: false`; see ADR 022 §Batch token accounting step 2).
451 /// 2. **Stage-2 rate limit.** Stage 1 already charged the inbound
452 /// frame in [`Self::serve`]; here we charge up to `n - 1` more
453 /// tokens via [`DhtRateLimiter::admit_batch_extra`]. The first
454 /// `k = 1 + granted` hashes proceed; the `n - k` tail is acked
455 /// `false` without per-hash processing.
456 /// 3. **Per-hash admission.** For each of the first `k` hashes: the
457 /// active-staker filter (batch-level holder, so the result is the
458 /// same for all hashes) then a receiver-anchored
459 /// [`RecordStore::insert_at`] sharing one `receive_us` for the
460 /// whole batch (ADR 022 §Content Records and TTL — a batch must
461 /// not have internally inconsistent TTLs).
462 //
463 // Linear admission sequence; same cognitive-complexity rationale as
464 // `handle_store`.
465 #[allow(clippy::cognitive_complexity)]
466 fn handle_batch_store(
467 &self,
468 peer: AuthenticatedNodeId,
469 peer_ip: Option<IpAddr>,
470 req: wire::BatchStoreRequest,
471 ) -> Result<wire::BatchStoreAck, u32> {
472 // Step 1: batch-level holder check. AC 19's size cap (≤ 256) is
473 // already enforced at wire decode (`deserialize_batch_hashes` →
474 // `MALFORMED_MESSAGE`), so by the time we're here `hashes.len()`
475 // is in range. `peer.node_id()` is the explicit assertion that we are
476 // comparing the wire `holder` against the authenticated identity.
477 if req.holder != peer.node_id() {
478 self.metrics.dht_store_rejected_holder_mismatch();
479 tracing::debug!(
480 holder = ?req.holder,
481 peer = %peer.node_id(),
482 "dht BatchStore rejected: holder != authenticated NodeId"
483 );
484 return Err(APP_ERR_MALFORMED_MESSAGE);
485 }
486 self.metrics.dht_batch_store_received();
487
488 let n = req.entries.len();
489 // Step 2: stage-2 token accounting. `k` hashes get per-hash
490 // processing; the rest are deferred (`false`) by the rate limit.
491 let extra = n.saturating_sub(1);
492 let granted = self
493 .rate_limiter
494 .admit_batch_extra(&peer.node_id(), peer_ip, extra);
495 let k = granted.saturating_add(1).min(n);
496 let deferred = n.saturating_sub(k);
497 if deferred > 0 {
498 self.metrics.dht_batch_store_hashes_deferred_rate_limit(
499 u64::try_from(deferred).unwrap_or(u64::MAX),
500 );
501 }
502
503 // Step 3: per-hash admission. The active-staker filter keys on
504 // the batch-level holder, so its verdict is identical for every
505 // hash — evaluate once. `receive_us` is anchored to a single
506 // arrival timestamp for the whole batch.
507 let active = self.staker_set.is_active(&req.holder);
508 if !active {
509 // Mirrors `handle_store` step 2's counter, once per rejected
510 // hash so the per-hash dashboards see the same volume a
511 // non-staked publisher's `n` separate `Store`s would produce.
512 for _ in 0..k {
513 self.metrics.dht_store_rejected_non_staked();
514 }
515 tracing::debug!(
516 holder = ?req.holder,
517 "dht BatchStore: holder not in active-staker set; all admitted hashes rejected"
518 );
519 }
520 let now_us = now_us();
521 // Lock the record store once for the whole batch: the loop has no
522 // `.await`, so per-hash locking would just add 256× lock/unlock
523 // churn and, on a poisoned mutex, log the error once per hash.
524 // Only the active path inserts, so don't acquire the lock at all
525 // when the staker filter already rejected the batch. A poisoned
526 // mutex → `None` → every admitted hash is acked `false`.
527 let mut store_guard = if active && k > 0 {
528 if let Ok(guard) = self.records.lock() {
529 Some(guard)
530 } else {
531 tracing::error!(
532 "dht BatchStore: record-store mutex poisoned; rejecting all hashes"
533 );
534 None
535 }
536 } else {
537 None
538 };
539 let mut results = Vec::with_capacity(n);
540 for (i, (hash, coverage)) in req.entries.into_iter().enumerate() {
541 // Tail beyond the rate-limit budget: deferred, no processing.
542 if i >= k {
543 results.push(false);
544 continue;
545 }
546 let accepted = if let Some(store) = store_guard.as_mut() {
547 let outcome = store.insert_at(req.holder, hash, coverage, now_us);
548 if outcome.accepted() {
549 self.metrics.dht_store_accepted();
550 true
551 } else {
552 self.metrics.dht_store_rejected_quota();
553 false
554 }
555 } else {
556 // Either the staker filter rejected the batch (`!active`)
557 // or the record-store mutex is poisoned — both ack `false`.
558 false
559 };
560 results.push(accepted);
561 }
562 Ok(wire::BatchStoreAck { results })
563 }
564
565 /// `Store` admission (ADR 022 §STORE Flow):
566 /// 1. `holder == authenticated NodeId` (caller invariant; rejects with
567 /// `accepted: false` on mismatch, never inserts).
568 /// 2. `StakerSet::is_active(holder)` (caller's cached set).
569 /// 3. Record-store insert under per-publisher quota + global LRU +
570 /// per-hash provider cap. The store returns whether the record
571 /// was newly inserted, refreshed, or hard-rejected at the
572 /// publisher cap — all of which map to `accepted` (refresh +
573 /// new) or `not accepted` (cap) on the wire.
574 // Linear admission sequence (auth check → staker filter → insert →
575 // counter bump per outcome). Splitting would scatter the ADR 022
576 // §STORE Flow rule order across helpers; same rationale as the
577 // `ProbeHandler::serve` cognitive-complexity allow upstream.
578 #[allow(clippy::cognitive_complexity)]
579 fn handle_store(&self, peer: AuthenticatedNodeId, req: wire::StoreRequest) -> wire::StoreAck {
580 // Step 1: holder must equal the QUIC-bound peer NodeId.
581 // ADR 022 §STORE Flow line 140: "The receiving node MUST reject
582 // any record whose `holder` does not equal the authenticated
583 // NodeId of the inbound QUIC connection." Bump a dedicated
584 // counter so the lying-`holder` attack rate is visible on the
585 // scrape without `RUST_LOG=debug`. `peer.node_id()` is the explicit
586 // assertion that the comparison is against the authenticated identity.
587 if req.holder != peer.node_id() {
588 self.metrics.dht_store_rejected_holder_mismatch();
589 tracing::debug!(
590 holder = ?req.holder,
591 peer = %peer.node_id(),
592 "dht Store rejected: holder != authenticated NodeId"
593 );
594 return wire::StoreAck {
595 hash: req.hash,
596 accepted: false,
597 };
598 }
599
600 // Step 2: active-staker filter. A sustained non-zero rate on
601 // this counter without matching `dht_store_accepted` growth
602 // signals a Sybil-attempt — an attacker rotating fresh NodeIds
603 // to spam records.
604 if !self.staker_set.is_active(&req.holder) {
605 self.metrics.dht_store_rejected_non_staked();
606 tracing::debug!(
607 holder = ?req.holder,
608 "dht Store rejected: holder not in active-staker set"
609 );
610 return wire::StoreAck {
611 hash: req.hash,
612 accepted: false,
613 };
614 }
615
616 // Step 3: receiver-anchored insert with all the cap rules.
617 let now_us = now_us();
618 let outcome = if let Ok(mut store) = self.records.lock() {
619 Some(store.insert_at(req.holder, req.hash, req.coverage, now_us))
620 } else {
621 // Poisoned record-store mutex: respond `accepted: false`
622 // so the publisher backs off rather than retrying into a
623 // node whose admission path is broken.
624 tracing::error!("dht Store: record-store mutex poisoned; rejecting publish");
625 None
626 };
627 let accepted = match outcome {
628 Some(o) if o.accepted() => {
629 self.metrics.dht_store_accepted();
630 true
631 }
632 Some(crate::dht::InsertOutcome::RejectedQuotaExceeded) => {
633 self.metrics.dht_store_rejected_quota();
634 false
635 }
636 // None = poisoned mutex; already logged above. No counter
637 // for this path — it should fire 0 times in a healthy node
638 // and the error log is the operator-actionable signal.
639 _ => false,
640 };
641 wire::StoreAck {
642 hash: req.hash,
643 accepted,
644 }
645 }
646
647 /// `FindValue` flow (ADR 022 §FIND\_VALUE Flow, responder side):
648 /// the responder returns its known holders for `hash` plus the
649 /// K-closest peers from the routing table. Per ADR 022 §Lookup
650 /// integrity, requester-side filtering (XOR-distance check, active-
651 /// staker filter, negative-probe-cache consultation, randomisation)
652 /// is the requester's job; we don't apply it here.
653 fn handle_find_value(&self, req: wire::FindValueRequest) -> wire::FindValueResponse {
654 let now_us = now_us();
655 let providers = if let Ok(mut store) = self.records.lock() {
656 store.providers_at(&req.hash, now_us)
657 } else {
658 tracing::error!("dht FindValue: record-store mutex poisoned; returning empty");
659 Vec::new()
660 };
661 wire::FindValueResponse {
662 hash: req.hash,
663 providers,
664 // A content hash and a NodeId share the 256-bit XOR keyspace
665 // (ADR 022 §Routing Table), so the hash is the keyspace point we
666 // measure routing-table peers against.
667 closer_nodes: self.closest_to(req.hash.as_bytes()),
668 }
669 }
670
671 fn handle_find_node(&self, req: wire::FindNodeRequest) -> wire::FindNodeResponse {
672 // NB: do NOT insert `req.requester` here. It is an attacker-
673 // controlled field on the wire — letting it through would allow
674 // an authenticated peer to inject arbitrary (potentially
675 // unreachable) NodeIds into the routing table without owning the
676 // matching keys (routing-table poisoning). The authenticated peer
677 // NodeId from the QUIC handshake is already refreshed once per
678 // admitted request in `serve()`, which is the only honest signal
679 // we have about who is on the other end.
680 let closer_nodes = self.closest_to(req.target.as_bytes());
681 wire::FindNodeResponse {
682 target: req.target,
683 closer_nodes,
684 }
685 }
686
687 /// The up-to-`MAX_CLOSER_NODES` routing-table peers closest to `target` in
688 /// XOR keyspace, ready to drop into a wire `closer_nodes` field. `target`
689 /// is a raw keyspace point so both a `NodeId` (`FindNode`) and a
690 /// `ContentHash` (`FindValue`) fit.
691 fn closest_to(&self, target: &[u8; 32]) -> wire::CloserNodes {
692 // Poison-tolerant: an empty answer from a poisoned guard would be an
693 // assertion about the table's contents this code cannot make, served
694 // to every inbound `FindNode`/`FindValue` for the process lifetime
695 // while the republisher (which recovers) keeps working — a node that
696 // looks healthy and answers every lookup with nothing.
697 with_lock(&self.routing, "dht routing table", |table| {
698 // `closest` already caps at `K_BUCKET_SIZE == MAX_CLOSER_NODES`, so the
699 // `CloserNodes` invariant always holds; `unwrap_or_default` is a
700 // belt-and-braces fallback that can't actually fire.
701 wire::CloserNodes::try_new(table.closest(target, MAX_CLOSER_NODES)).unwrap_or_default()
702 })
703 }
704}
705
706impl ProtocolHandler for DhtHandler {
707 async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
708 self.serve(connection)
709 .await
710 .map_err(|e| AcceptError::from_boxed(e.into()))
711 }
712}
713
714const fn frame_err_code(e: &FrameError) -> u32 {
715 match e {
716 FrameError::TooLarge(_) => APP_ERR_MESSAGE_TOO_LARGE,
717 FrameError::Io(_) | FrameError::Varint | FrameError::Decode(_) => APP_ERR_MALFORMED_MESSAGE,
718 }
719}
720
721struct DhtReadError {
722 err: anyhow::Error,
723 app_code: u32,
724}
725
726/// Request variants the DHT handler accepts on a server stream.
727///
728/// The enum is the narrowed shape produced by [`read_dht_request`] — it
729/// excludes the response variants (responses belong on the client side of
730/// the stream). Carrying the narrowing in a type, rather than a runtime
731/// match-all fallback inside `dispatch`, keeps the dispatcher exhaustive
732/// over the request set and prevents an accidentally-silent "I echoed a
733/// bogus `FindNodeResponse`" failure mode if the wire enum grows new
734/// response variants.
735enum DhtServerRequest {
736 FindNode(wire::FindNodeRequest),
737 FindValue(wire::FindValueRequest),
738 Store(wire::StoreRequest),
739 BatchStore(wire::BatchStoreRequest),
740}
741
742async fn read_dht_request(
743 send: &mut SendStream,
744 recv: &mut RecvStream,
745) -> Result<DhtServerRequest, DhtReadError> {
746 let reset = |send: &mut SendStream, recv: &mut RecvStream, code: u32| {
747 let v = VarInt::from_u32(code);
748 let _ = send.reset(v);
749 let _ = recv.stop(v);
750 };
751
752 let frame = match tokio::time::timeout(READ_TIMEOUT, read_frame(recv)).await {
753 Err(_) => {
754 reset(send, recv, 0);
755 return Err(DhtReadError {
756 err: anyhow::anyhow!("dht request read timed out after {READ_TIMEOUT:?}"),
757 app_code: 0,
758 });
759 }
760 Ok(Err(e)) => {
761 let app_code = frame_err_code(&e);
762 reset(send, recv, app_code);
763 return Err(DhtReadError {
764 err: anyhow::anyhow!("dht frame read failed: {e}"),
765 app_code,
766 });
767 }
768 Ok(Ok(frame)) => frame,
769 };
770
771 match decode_message::<wire::DhtMessage>(&frame) {
772 Err(e) => {
773 // ADR 013: an unknown enum discriminant closes with
774 // UNSUPPORTED_MESSAGE (0x01), not MALFORMED_MESSAGE (0x03). A
775 // genuine parse fault (in-range discriminant, bad payload — e.g.
776 // an over-cap `BatchStore` rejected by `deserialize_batch_hashes`)
777 // stays MALFORMED.
778 let app_code = if is_unknown_variant::<wire::DhtMessage>(&frame) {
779 APP_ERR_UNSUPPORTED_MESSAGE
780 } else {
781 APP_ERR_MALFORMED_MESSAGE
782 };
783 reset(send, recv, app_code);
784 Err(DhtReadError {
785 err: anyhow::anyhow!("dht decode failed: {e}"),
786 app_code,
787 })
788 }
789 Ok((msg, tail)) => {
790 // A `Store` carries a Tier-1 extension; parsing it here is what makes the
791 // seam real rather than a reserved name. Every other request leaves an
792 // empty remainder, which reads back as the default.
793 if matches!(msg, wire::DhtMessage::Store(_))
794 && let Err(e) = wire::parse_store_request_ext(tail)
795 {
796 reset(send, recv, APP_ERR_MALFORMED_MESSAGE);
797 return Err(DhtReadError {
798 err: anyhow::anyhow!("dht store extension decode failed: {e}"),
799 app_code: APP_ERR_MALFORMED_MESSAGE,
800 });
801 }
802 // Narrow the full wire enum down to the handler-supported
803 // request set. Response variants on a server-accepted stream
804 // close with `APP_ERR_UNSUPPORTED_MESSAGE`: the handler is a
805 // request-only endpoint; responses belong on the client side.
806 // `BatchStore` is a request the handler now implements (#648)
807 // — it routes through `handle_batch_store`. Note an oversize
808 // `BatchStore` (> `MAX_BATCH_STORE_HASHES`) never reaches this
809 // match: `deserialize_batch_hashes` rejects it at the
810 // `decode_message` step above, which maps to
811 // `APP_ERR_MALFORMED_MESSAGE` (AC 19).
812 match msg {
813 wire::DhtMessage::FindNode(req) => Ok(DhtServerRequest::FindNode(req)),
814 wire::DhtMessage::FindValue(req) => Ok(DhtServerRequest::FindValue(req)),
815 wire::DhtMessage::Store(req) => Ok(DhtServerRequest::Store(req)),
816 wire::DhtMessage::BatchStore(req) => Ok(DhtServerRequest::BatchStore(req)),
817 wire::DhtMessage::FindNodeResponse(_)
818 | wire::DhtMessage::FindValueResponse(_)
819 | wire::DhtMessage::StoreAck(_)
820 | wire::DhtMessage::BatchStoreAck(_) => {
821 reset(send, recv, APP_ERR_UNSUPPORTED_MESSAGE);
822 Err(DhtReadError {
823 err: anyhow::anyhow!("peer sent dht response on server stream"),
824 app_code: APP_ERR_UNSUPPORTED_MESSAGE,
825 })
826 }
827 }
828 }
829 }
830}
831
832/// Receiver-side wall-clock at microsecond resolution (ADR 022 §Content
833/// Records and TTL: `expiry_us = receive_us + record_ttl_us` is anchored
834/// on the receiver's wall-clock). `SystemTime::UNIX_EPOCH.elapsed()` is
835/// used rather than `Instant::now()` because the value is stored in
836/// records that survive process restarts (in a future persistence
837/// extension) and a monotonic clock would not be comparable across
838/// reboots.
839///
840/// On a wall-clock before-epoch (unset hardware clock) this returns 0;
841/// the `RecordStore` then folds in its monotonic counter so subsequent
842/// inserts still produce strictly-increasing keys.
843fn now_us() -> u64 {
844 // A wall-clock before UNIX_EPOCH means the operator's hardware
845 // clock is mis-set — every record inserted while this is true
846 // collapses to `receive_us = 0` and they all expire on the same
847 // tick when the clock recovers. Surface it as an operator-visible
848 // error (not a debug-level swallow) so the failure mode is
849 // diagnosable from logs. The 0 fallback keeps the store
850 // functional in the degraded state — `next_sequence` still
851 // disambiguates LRU keys so admission never panics.
852 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
853 Ok(d) => u64::try_from(d.as_micros()).unwrap_or(u64::MAX),
854 Err(err) => {
855 tracing::error!(
856 error = %err,
857 "wall-clock before UNIX_EPOCH; falling back to receive_us=0 — \
858 DHT records will expire en masse when the clock recovers. Set the host clock."
859 );
860 0
861 }
862 }
863}
864
865#[cfg(test)]
866mod tests;