Skip to main content

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;