decdn_node/handlers/probe.rs
1//! `cdn/probe/v1` handler — unauthenticated latency + rate probe.
2
3use std::num::NonZeroUsize;
4use std::sync::Arc;
5use std::time::Duration;
6
7use alloy::dyn_abi::Eip712Domain;
8use alloy::primitives::B256;
9use alloy::signers::local::PrivateKeySigner;
10use decdn_cache::{CacheEngine, Hash, ProbeHoldOutcome};
11use decdn_incentive::ProbeSlashData;
12use decdn_protocol::{
13 ALPN_PROBE, APP_ERR_RATE_LIMITED, Coverage, FrameError, ProbeMessage, ProbeResponseBody,
14 ProbeResponseExt, decode_message, encode_probe_response, is_unknown_variant,
15 message::ProbeResponse, num_blocks, read_frame, write_frame,
16};
17use iroh::PublicKey;
18use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt};
19use iroh::protocol::{AcceptError, ProtocolHandler};
20
21use crate::dht::routing::NodeId;
22use crate::dht::staker_set::StakerSet;
23use crate::dispatch::{ConnectionLimiter, RejectReason};
24use crate::handlers::probe_rate_limit::{ProbeRateLimiter, ProbeRejectLayer};
25use crate::metrics::{Metrics, ProbeHoldUnavailableReason};
26use crate::warn_throttle::WarnThrottle;
27
28// Server-side timeouts. Each ceiling exists so a single peer cannot pin a
29// handler task indefinitely by stalling at one of the protocol's ordered
30// steps.
31const ACCEPT_BI_TIMEOUT: Duration = Duration::from_secs(5);
32const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
33const PROBE_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
34/// Bound on the post-rejection close-frame flush. The QUIC `CONNECTION_CLOSE`
35/// frame is best-effort; we wait briefly for the peer to acknowledge so the
36/// `0x10 RATE_LIMITED` reason byte (and its layer label) reach them, but cap
37/// the wait so a malicious flooder can't keep the handler alive by refusing
38/// to acknowledge.
39const REJECTION_CLOSE_TIMEOUT: Duration = Duration::from_millis(250);
40
41/// Minimum gap between probe request read-fault `warn!` lines. Each line
42/// carries the offending `peer` and the `suppressed` count.
43const PROBE_READ_FAULT_WARN_INTERVAL: Duration = Duration::from_mins(1);
44
45// QUIC application error codes defined by ADR 013 §Application Error Codes.
46//
47// `APP_ERR_NO_ERROR = 0` is the implicit QUIC "no app error" default and is
48// what the handler returns for transport-level conditions (read timeout,
49// mid-frame EOF, peer reset) where no protocol fault occurred. Named to
50// avoid magic-number drift; #577 M1 motivated splitting it out from
51// `APP_ERR_MALFORMED_MESSAGE` so peers don't apply protocol-fault backoff
52// to a transport drop.
53const APP_ERR_NO_ERROR: u32 = 0x00;
54const APP_ERR_UNSUPPORTED_MESSAGE: u32 = 0x01;
55const APP_ERR_MESSAGE_TOO_LARGE: u32 = 0x02;
56const APP_ERR_MALFORMED_MESSAGE: u32 = 0x03;
57
58/// Stake-lane probe-acceptance policy (#757, ADR 003 §Admission and
59/// Priority). Reserves probe-hold headroom for stake-lane requesters —
60/// registered operators issuing node-to-node cache-miss probes — so
61/// end-client probe load cannot starve them under hold-budget pressure.
62///
63/// "Stake lane" is the `CapacityBond.isActive` predicate (registered + bond
64/// ≥ minBond + not unbonding + not ejected), resolved from the same
65/// chain-followed [`StakerSet`] the DHT handler consumes — no per-probe
66/// chain read and no new chain wiring (the issue's constraint). It is a
67/// faithful binary reading of ADR 003's `bondOf >= bond_required(...)`
68/// prioritization; finer capacity-scaled bond tiering is out of scope.
69///
70/// Construction is gated by the operator: the runtime only builds this when
71/// `cache.stake_lane_reserved_holds > 0`, so a single-lane deployment passes
72/// `None` and the handler skips the stake-lane check entirely.
73#[derive(Clone)]
74pub struct StakeLanePolicy {
75 /// Chain-followed active-staker set, keyed by iroh `NodeId`. The probe
76 /// requester's id (`conn.remote_id()`) is looked up directly — the
77 /// `CapacityBond` holds the NodeId↔operator binding, so a `true` here
78 /// means the requester is a registered, sufficiently-bonded operator.
79 staker_set: Arc<dyn StakerSet>,
80 /// Number of hold slots reserved for the stake lane. End-client probes
81 /// are shed once `probe_hold_slots_used >= max_holds.saturating_sub(reserved_holds)`
82 /// (the `saturating_sub` makes `reserved_holds >= max_holds` clamp the
83 /// end-client ceiling to `0`, i.e. "stake lane only"). Typed
84 /// [`NonZeroUsize`] so the "reservation off" case is *unrepresentable*
85 /// here — it is encoded as `None` at the call site — rather than relying
86 /// on a documented `> 0` convention.
87 reserved_holds: NonZeroUsize,
88 /// The configured `max_probe_holds` budget — the ceiling the reservation
89 /// is subtracted from. Carried here (rather than re-read from the cache)
90 /// because `max_probe_holds` is restart-required, so the value is stable
91 /// for the handler's lifetime.
92 max_holds: usize,
93}
94
95impl std::fmt::Debug for StakeLanePolicy {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("StakeLanePolicy")
98 .field("reserved_holds", &self.reserved_holds)
99 .field("max_holds", &self.max_holds)
100 .finish_non_exhaustive()
101 }
102}
103
104impl StakeLanePolicy {
105 /// Build a policy. `reserved_holds` is [`NonZeroUsize`] so the "off" case
106 /// (a zero reservation) cannot be constructed — callers encode it as
107 /// `None`. `max_holds` is the configured `max_probe_holds`.
108 #[must_use]
109 pub fn new(
110 staker_set: Arc<dyn StakerSet>,
111 reserved_holds: NonZeroUsize,
112 max_holds: usize,
113 ) -> Self {
114 Self {
115 staker_set,
116 reserved_holds,
117 max_holds,
118 }
119 }
120}
121
122/// Serves `cdn/probe/v1`: reads a framed [`ProbeMessage::Request`], writes a
123/// framed [`ProbeMessage::Response`].
124///
125/// `rate_per_mb` is the served price, fixed at startup. Reprice by restarting
126/// the daemon (see `runtime::reload::warn_restart_required_sections`).
127pub struct ProbeHandler {
128 node_id: PublicKey,
129 rate_per_mb: u64,
130 metrics: Arc<Metrics>,
131 limiter: Arc<ConnectionLimiter>,
132 /// ADR 005 §Probe rate limiting three-layer token-bucket limiter
133 /// (global → per-IP → per-peer). Runs in addition to `limiter` (the
134 /// connection-level [`ConnectionLimiter`]) — see
135 /// [`crate::handlers::probe_rate_limit`] for why both layers run.
136 probe_rate_limiter: Arc<ProbeRateLimiter>,
137 /// Cache engine — queried for blob presence and the probe-triggered
138 /// eviction hold (ADR 005 §Probe-triggered eviction hold).
139 cache: CacheEngine,
140 /// Operator Ethereum key used to produce the EIP-712 `slash_sig`
141 /// (#406 loads it; ADR 014 §1 mandates it on every response).
142 eth_signer: Arc<PrivateKeySigner>,
143 /// `SlashJudge` EIP-712 domain, built once from
144 /// `blockchain.{slash_judge_address,chain_id}`.
145 slash_domain: Eip712Domain,
146 /// Optional stake-lane probe-acceptance reservation (#757). `None` (the
147 /// single-lane default) admits every hold from one shared budget with no
148 /// reserved headroom; `Some` reserves hold headroom for registered
149 /// node-to-node requesters under budget pressure.
150 stake_lane: Option<StakeLanePolicy>,
151 /// Mirrors `ResolvedCache::relay_foreign_namespaces` (ADR 002 origin-only
152 /// node policy). `true` (the default) is the unchanged relay path below.
153 /// `false` restricts `has_blob` to backend-held content only — a
154 /// store-present blob it will now decline to relay is never advertised —
155 /// so a foreign decline never reads as "advertised but didn't serve".
156 relay_foreign_namespaces: bool,
157 /// Liveness of the node's chain reads (ADR 011 § Serving while chain-stale).
158 /// `Some` only when the blacklist watcher is wired; while it reads stale the
159 /// handler answers `has_blob: false` for every hash rather than sign a
160 /// `ProbeResponse` advertising content whose takedown status it can no longer
161 /// confirm. `None` (dev/test, no chain) leaves the presence answer unchanged.
162 chain_freshness: Option<crate::chain_freshness::ChainFreshness>,
163 /// Throttle for the `warn!` on a probe request that times out, fails to
164 /// frame or decode, or arrives as the wrong message. The probe ALPN is
165 /// unauthenticated, so any peer triggers it at will.
166 read_fault_warn: WarnThrottle,
167}
168
169impl std::fmt::Debug for ProbeHandler {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 f.debug_struct("ProbeHandler")
172 .field("node_id", &self.node_id)
173 .field("rate_per_mb", &self.rate_per_mb)
174 .finish_non_exhaustive()
175 }
176}
177
178impl ProbeHandler {
179 /// The ALPN this handler answers on: `cdn/probe/v1`, the unauthenticated
180 /// latency and availability probe.
181 pub const ALPN: &'static [u8] = ALPN_PROBE;
182
183 /// Build the probe handler from the runtime state it answers out of: the
184 /// node's identity and advertised rate, the rate limiters that gate an
185 /// unauthenticated caller, and the signer that attests each response.
186 #[allow(clippy::missing_const_for_fn)] // Arc::new isn't const.
187 #[allow(clippy::too_many_arguments)] // wiring struct; each arg is distinct runtime state.
188 pub fn new(
189 node_id: PublicKey,
190 rate_per_mb: u64,
191 metrics: Arc<Metrics>,
192 limiter: Arc<ConnectionLimiter>,
193 probe_rate_limiter: Arc<ProbeRateLimiter>,
194 cache: CacheEngine,
195 eth_signer: Arc<PrivateKeySigner>,
196 slash_domain: Eip712Domain,
197 stake_lane: Option<StakeLanePolicy>,
198 relay_foreign_namespaces: bool,
199 chain_freshness: Option<crate::chain_freshness::ChainFreshness>,
200 ) -> Self {
201 Self {
202 node_id,
203 rate_per_mb,
204 metrics,
205 limiter,
206 probe_rate_limiter,
207 cache,
208 eth_signer,
209 slash_domain,
210 stake_lane,
211 relay_foreign_namespaces,
212 chain_freshness,
213 read_fault_warn: WarnThrottle::new(PROBE_READ_FAULT_WARN_INTERVAL),
214 }
215 }
216
217 /// The advertised `total_bytes` for a blob already established as present.
218 ///
219 /// `total_bytes` is an unsigned, optional hint outside the `slash_sig`
220 /// typehash, so `None` degrades cleanly to "unknown but fetchable" — no
221 /// consumer treats it as a presence signal. An `Err` here is still worth a
222 /// log: every caller has *just* proven the blob present, so a failing
223 /// `inspect` means the store started degrading in between, which is a
224 /// strictly stronger signal than the plain miss the `Err` arms warn about.
225 async fn inspect_size(&self, hash: Hash) -> Option<u64> {
226 match self.cache.inspect(hash).await {
227 Ok(preview) => preview.size_bytes,
228 Err(e) => {
229 tracing::warn!(
230 error = %e.display_chain(),
231 %hash,
232 "inspect failed for a blob just proven present; advertising without a size"
233 );
234 None
235 }
236 }
237 }
238
239 // Linear, ordered protocol sequence (limit → accept → read → hold →
240 // clamp → sign → write → close). Splitting it would scatter the ADR-013
241 // app-error-code mapping and the ADR-005 ordering invariants across
242 // helpers and make them harder to audit against the spec — same
243 // rationale as the `read_probe_request` cognitive-complexity allow.
244 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
245 async fn serve(&self, conn: Connection) -> anyhow::Result<()> {
246 let _permit = match self.limiter.acquire(&conn) {
247 Ok(p) => p,
248 Err(reason) => {
249 // Rate-limited rejection is normal load-shedding, not a
250 // protocol fault: returning `Err` here would have iroh log
251 // every rejection as an `AcceptError`, amplifying log
252 // volume under flood (exactly what the attacker wants).
253 // The dispatch layer already emits a structured debug log
254 // and a metric counter for the rejection.
255 conn.close(
256 VarInt::from_u32(APP_ERR_RATE_LIMITED),
257 reason.as_str().as_bytes(),
258 );
259 // Wait for the close frame to be acknowledged so the peer
260 // reliably observes the 0x10 RATE_LIMITED code and the
261 // layer-label reason byte — except on `GlobalFull`. Under
262 // a global-cap flood every rejection would otherwise
263 // park a task here for up to `REJECTION_CLOSE_TIMEOUT`,
264 // and at thousands of rejections per second that is the
265 // memory-pressure path the limiter exists to prevent.
266 // Layer label is least useful for `GlobalFull` anyway
267 // (operators pivot on the metric counter, not the close
268 // reason byte). Per-source rejections are rate-limited
269 // by the bucket itself, so the bounded wait is safe.
270 if reason != RejectReason::GlobalFull {
271 let _ = tokio::time::timeout(REJECTION_CLOSE_TIMEOUT, conn.closed()).await;
272 }
273 return Ok(());
274 }
275 };
276
277 // ADR 005 §Probe rate limiting: three-layer token-bucket limiter
278 // (global → per-IP → per-peer) applied *before* any signature is
279 // computed and *before* any eviction-hold slot is allocated. It runs in
280 // addition to the `ConnectionLimiter` permit above (per-source IP +
281 // global concurrency) — see `probe_rate_limit` module docs for why
282 // both run. Both the requester NodeId and the source IP are available
283 // pre-stream, so a rejected probe never opens a bidi stream.
284 let requester = NodeId::from_bytes(*conn.remote_id().as_bytes());
285 let peer_ip = crate::rate_limit::peer_ip(&conn);
286 if let Err(layer) = self.probe_rate_limiter.check(&requester, peer_ip) {
287 // Load-shedding, not a protocol fault: don't return `Err` (which
288 // iroh would log per rejection, amplifying log volume under flood —
289 // exactly what the attacker wants). The limiter already bumped the
290 // per-layer rejection counter; emit a debug log too so operators
291 // can correlate the offending peer/IP, matching the per-source
292 // (`ConnectionLimiter::acquire_inner`) and DHT
293 // (`close_stream_with_rate_limit`) reject paths.
294 tracing::debug!(
295 layer = layer.as_str(),
296 requester = ?requester,
297 peer_ip = ?peer_ip,
298 "probe request rejected by the three-layer rate limiter"
299 );
300 conn.close(
301 VarInt::from_u32(APP_ERR_RATE_LIMITED),
302 layer.as_str().as_bytes(),
303 );
304 // Wait briefly for the close frame so the peer observes the 0x10
305 // RATE_LIMITED code and the layer label — except on the global
306 // layer, where a flood would otherwise park a task per rejection
307 // (operators pivot on the metric counter, not the close reason,
308 // for a global flood). Per-peer/per-IP rejections are bounded by
309 // their own buckets, so the capped wait is safe.
310 if layer != ProbeRejectLayer::Global {
311 let _ = tokio::time::timeout(REJECTION_CLOSE_TIMEOUT, conn.closed()).await;
312 }
313 return Ok(());
314 }
315
316 let _guard = self.metrics.connection_guard();
317
318 // ADR 005 caps probe at 1 bidi stream per connection; the transport-level
319 // cap is the union across ALPNs, so probe's tighter bound is enforced
320 // here by accepting exactly one stream and then closing.
321 let (mut send, mut recv) = tokio::time::timeout(ACCEPT_BI_TIMEOUT, conn.accept_bi())
322 .await
323 .map_err(|_| anyhow::anyhow!("accept_bi timed out after {ACCEPT_BI_TIMEOUT:?}"))?
324 .map_err(|e| anyhow::anyhow!("accept_bi failed: {e}"))?;
325
326 let req = match read_probe_request(&mut send, &mut recv).await {
327 Ok(req) => req,
328 Err(ProbeReadError { err, app_code }) => {
329 self.metrics.probe_read_fault();
330 if let Some(suppressed) = self.read_fault_warn.admit() {
331 tracing::warn!(
332 peer = %conn.remote_id(),
333 app_code,
334 error = %err,
335 suppressed,
336 interval = ?self.read_fault_warn.interval(),
337 "probe request read failed"
338 );
339 }
340 // ADR 013 scopes app error codes to streams, but probe is 1:1
341 // connection:stream — also close the connection with the same
342 // code so the peer observes it deterministically even if the
343 // stream RESET racing with connection teardown gets clobbered.
344 conn.close(VarInt::from_u32(app_code), b"probe-error");
345 // Handled: the peer has its close code and the fault is counted
346 // and logged above. An `Err` here would make iroh's router log
347 // it again, unthrottled, once per bad probe from any peer.
348 return Ok(());
349 }
350 };
351
352 let hash = Hash::from_bytes(req.hash);
353
354 // Stake-lane probe-acceptance reservation (#757, ADR 003 §Admission
355 // and Priority). Under hold-budget pressure, reserve the last
356 // `reserved_holds` slots for stake-lane requesters (registered
357 // operators issuing node-to-node cache-miss probes) so end-client
358 // load cannot starve them. Evaluated *before* `try_probe_hold` and
359 // gated by `self.stake_lane`, so it is a strict no-op for
360 // single-lane operators (the `None` default). Soft and best-effort:
361 // the slots-used read races a concurrent hold, which is acceptable
362 // for a priority heuristic (admission is implementation-defined per
363 // ADR 003) and never a safety property.
364 let stake_lane_reserved = match self.stake_lane.as_ref() {
365 Some(policy) => {
366 let used = self.cache.probe_hold_slots_used();
367 // `is_stake_lane` is resolved lazily: `stake_lane_reserved_out`
368 // calls it only after the cheap ceiling guards pass, so the
369 // uncongested common path skips the staker-set lookup (an
370 // RwLock read + NodeId conversion) entirely (#757 review).
371 stake_lane_reserved_out(policy.reserved_holds.get(), policy.max_holds, used, || {
372 let requester = NodeId::from_bytes(*conn.remote_id().as_bytes());
373 policy.staker_set.is_active(&requester)
374 })
375 }
376 None => false,
377 };
378
379 // Tracks whether the eventual `has_blob: true` (if any) is store- or
380 // origin-sourced, and whether the operator has explicitly opted the
381 // store out of advertisement (`max_probe_holds == 0`) — both feed the
382 // final coverage derivation below, after `has_blob`/`total_bytes`
383 // settle. `store_present` mirrors the store branch's own verdict
384 // *before* the origin fold below can flip a `false` to `true`, so a
385 // post-fold `true` with `!store_present` unambiguously means "the
386 // origin, not the store, is why we can serve this".
387 let mut store_present = false;
388 let mut holds_disabled = false;
389
390 // Chain-staleness gate (ADR 011 §Serving while chain-stale). A
391 // `has_blob: true` is signed slash evidence (ADR 011 §Slash evidence), and
392 // while the node cannot reach the chain it cannot confirm this hash was not
393 // taken down during the blind window. When stale the node advertises
394 // nothing: `has_blob: false` is a reputation-benign true negative, so a
395 // client routes to a peer whose reads are live — the same posture the
396 // serve-admit gate in `dispatch.rs` takes. `None` (no chain wired —
397 // dev/test) leaves the answer unchanged. Read once and applied in two
398 // places: it short-circuits the presence computation below (no store read,
399 // no origin fold, no live HEAD), AND forces the final coverage empty, since
400 // `has_blob` is re-derived from `coverage` — gating only the presence
401 // computation would let the coverage read re-affirm the held blob.
402 let chain_stale = self
403 .chain_freshness
404 .as_ref()
405 .is_some_and(crate::chain_freshness::ChainFreshness::is_stale);
406
407 let (has_blob, total_bytes) = if chain_stale {
408 (false, None)
409 } else if self.relay_foreign_namespaces {
410 // Probe-triggered eviction hold (ADR 005 §Probe-triggered eviction
411 // hold). The hold is **best-effort**: a node answers `has_blob: true`
412 // whenever it holds the blob and has not refused it, then keeps a 35s
413 // eviction hold *if it can* so the blob is still resident for the
414 // follow-up pull. Budget pressure or a stake-lane reservation only means
415 // "place no hold" — never "deny an honest answer", so a probe flood that
416 // fills the hold cache can never suppress a truthful `has_blob`.
417 //
418 // What an unheld advertisement actually costs, if the blob is
419 // LRU-evicted before the pull lands: one wasted round trip. The delivery
420 // path answers a plain `NotFound` and the requester goes elsewhere. It
421 // is NOT a slash (no offense pairs a probe with a miss — ADR 014), and
422 // it is NOT a reputation penalty either: `classify_refusal` rates
423 // `NotFound` as `RefusalVerdict::Transient`, which briefly suppresses
424 // the (peer, hash) pair *without* scoring the peer, because a miss is
425 // not attributable to it (see `node_origin.rs`). Do not justify this
426 // path by reputation — nothing records an outcome for it.
427 //
428 // The hold is attempted *after* the rate limiter (ADR 005 §Probe rate
429 // limiting: hold admission occurs only after the limiter passes — do not
430 // reorder).
431 let (has_blob, total_bytes) = if stake_lane_reserved {
432 // End-client shed to protect stake-lane hold headroom (#757): place
433 // no hold, but still answer honestly — presence does not depend on a
434 // slot. `has()` already folds in `refuses()` (denied / chain-denied /
435 // operator-evicted), so it alone settles presence; the second
436 // `refuses` call is not a distinct predicate but a re-check that
437 // narrows the TOCTOU window the `await` opens, because signing
438 // `has_blob: true` for a blacklisted hash is a blacklist-violation
439 // offense (ADR 014).
440 self.metrics
441 .probe_hold_unavailable(ProbeHoldUnavailableReason::StakeLaneReserved);
442 match self.cache.has(hash).await {
443 Ok(true) if !self.cache.refuses(hash) => (true, self.inspect_size(hash).await),
444 Ok(_) => (false, None),
445 // Same reasoning as the `try_probe_hold` `Err` arm below: a store
446 // fault is not "absent" and must not be silently indistinguishable
447 // from one. This path runs only under hold pressure — precisely
448 // when a degrading backend is most worth surfacing.
449 Err(e) => {
450 tracing::warn!(
451 error = %e.display_chain(),
452 %hash,
453 "cache error during stake-lane shed presence check; \
454 answering has_blob:false"
455 );
456 (false, None)
457 }
458 }
459 } else {
460 match self.cache.try_probe_hold(hash).await {
461 Ok(ProbeHoldOutcome::Held) => (true, self.inspect_size(hash).await),
462 Ok(ProbeHoldOutcome::BudgetExhausted) => {
463 // Present but every hold slot is live: advertise anyway and
464 // forgo the hold. A probe flood that fills the best-effort
465 // hold cache cannot suppress an honest answer — the blob is
466 // here now (a `BudgetExhausted` outcome already means present
467 // and not refused). If it is evicted before the pull, that
468 // costs one wasted round trip (see the block comment above).
469 // Raising `max_probe_holds` is still the remedy for the lost
470 // holds (#739).
471 self.metrics
472 .probe_hold_unavailable(ProbeHoldUnavailableReason::Exhausted);
473 (true, self.inspect_size(hash).await)
474 }
475 Ok(ProbeHoldOutcome::HoldsDisabled) => {
476 // Holds disabled by config (`max_probe_holds == 0`) is the
477 // operator's explicit opt-out from advertising *store-backed*
478 // content: answer `has_blob: false` so this node is not
479 // selected off the evictable store. Distinct from budget
480 // pressure (which still advertises) — a deliberate choice,
481 // not load (#739).
482 //
483 // It is not a blanket "answer false to everything": the
484 // origin-held fallback below takes no hold and so is
485 // unaffected by this setting. A node with a configured origin
486 // still advertises origin-servable content with
487 // `max_probe_holds == 0`.
488 self.metrics
489 .probe_hold_unavailable(ProbeHoldUnavailableReason::Disabled);
490 holds_disabled = true;
491 (false, None)
492 }
493 // Blob genuinely absent, operator-evicted, or refused
494 // (blacklisted/denied) — a true negative, no signal needed.
495 Ok(ProbeHoldOutcome::Unavailable) => (false, None),
496 // A transient cache fault is *not* the same as "absent": the
497 // node may actually hold the blob. We still conservatively
498 // answer `has_blob: false` (a store it cannot read is one it
499 // cannot serve), but a degrading backend must be
500 // operator-visible rather than indistinguishable from a normal
501 // miss. The registry has no metric for this; a warn log is the
502 // actionable signal.
503 Err(e) => {
504 tracing::warn!(
505 error = %e.display_chain(),
506 "cache error during probe hold check; answering has_blob:false"
507 );
508 (false, None)
509 }
510 }
511 };
512 store_present = has_blob;
513
514 // Origin-held fallback (#1130). If the store can't back a
515 // `has_blob: true` — the blob was never pulled into it, holds are
516 // disabled, or the store read faulted — but it is servable from a
517 // configured origin (fs directory entry, or a present pin), advertise it
518 // anyway so cold origin content is discoverable on the first probe
519 // rather than only after a warm.
520 //
521 // Origin content takes **no** eviction hold: it lives on disk / behind
522 // the origin, not in the evictable store, so hold-budget pressure and
523 // `max_probe_holds` are both irrelevant to it — which is why
524 // `max_probe_holds == 0` silences store-backed advertisements but not
525 // these. If the origin object later vanishes before the pull, the
526 // delivery path answers a plain miss and the requester retries
527 // elsewhere: one wasted round trip, not a slash (ADR 014) and not a
528 // reputation penalty (see the hold block comment above).
529 let (has_blob, total_bytes) =
530 fold_origin_held((has_blob, total_bytes), self.cache.origin_held_size(hash));
531 // Live-origin HEAD fallback (#1130 pt3). The index above covers fs
532 // enumeration ∪ pins; http/s3 do not list, so a non-pinned bucket object
533 // is invisible to it. When nothing has answered `true` yet, consult the
534 // origin directly (`HEAD`/`HeadObject`, no body) with a TTL memo so a
535 // non-pinned remote object is discoverable on the first probe rather than
536 // never. Skipped once `has_blob` is already true — no point paying a HEAD
537 // for a blob the store or the index already backs.
538 if has_blob {
539 (has_blob, total_bytes)
540 } else {
541 fold_origin_held(
542 (has_blob, total_bytes),
543 self.cache.origin_probe_size(hash).await,
544 )
545 }
546 } else {
547 // Origin-only nodes (ADR 002 origin-only node policy): advertise
548 // exactly what the serve gate will honor, which is backend-held
549 // content only. A store hit for a blob outside this node's own
550 // origin(s) must NOT be advertised here, or a later foreign-namespace
551 // serve decline reads as "advertised but didn't serve" (the reputation
552 // hazard this policy closes). This short-circuits the entire
553 // store-present / probe-hold / origin-held-index derivation above —
554 // presence is decided purely by the backend probe (index-or-live-HEAD,
555 // memoised) — and, like every other path here, still answers `false`
556 // for a `refuses`-listed hash (`origin_probe_size` checks it first).
557 match self.cache.origin_probe_size(hash).await {
558 Some(size) => (true, Some(size)),
559 None => (false, None),
560 }
561 };
562
563 // Final will-serve coverage (#1506): `has_blob` is redefined from
564 // "holds the whole blob" to "will serve at least one discovery
565 // block", so it is derived FROM `coverage` rather than the other way
566 // around — the two are a biconditional by construction, never two
567 // independently-set fields that could drift apart.
568 //
569 // - Origin-sourced (`has_blob` flipped true by the fold above, not by
570 // the store branch): an origin serves every block and already
571 // handed us the size via `total_bytes`, so it is all-ones.
572 // - Store-sourced or absent, and NOT the operator's explicit
573 // `max_probe_holds == 0` opt-out: read the cache's own
574 // cached-block bitmap. This is what upgrades a partial (non-
575 // `Complete`) holder from the old `has_blob: false` to an honest
576 // partial advertisement — `try_probe_hold`'s `has()` gate above
577 // only recognizes `Complete` blobs, but a partial holder that
578 // covers at least one block can still serve it.
579 // - `max_probe_holds == 0`: the operator opted the STORE out of
580 // advertisement; honor that by forcing empty rather than letting a
581 // cached partial leak back in under it (origin-held content is
582 // unaffected — it never reaches this arm because it is already
583 // `is_origin_sourced`).
584 let is_origin_sourced = has_blob && !store_present;
585 // The coverage and the size the response advertises, bound together: a
586 // partial holder reads its size from the same bitfield as its blocks
587 // (#2195), because the store branch above sizes only a `Complete` blob and
588 // ADR 005 asks a node to include the size whenever it knows it.
589 let (coverage, total_bytes) = if chain_stale {
590 // Chain-stale (above): advertise no coverage, so the re-derived
591 // `has_blob` below stays false. This is the arm that actually binds the
592 // stale answer — `has_blob` is the coverage biconditional.
593 (Coverage::empty(), total_bytes)
594 } else if is_origin_sourced {
595 (
596 total_bytes.map_or_else(Coverage::empty, |size| Coverage::full(num_blocks(size))),
597 total_bytes,
598 )
599 } else if holds_disabled || !self.relay_foreign_namespaces {
600 // `holds_disabled`: the operator's explicit store opt-out (above).
601 // `!self.relay_foreign_namespaces`: the origin-only policy (ADR
602 // 002, #1759) — presence there is decided purely by the backend
603 // probe, and a store hit for a blob outside this node's own
604 // origin must stay unadvertised (a later serve decline on it
605 // would read as "advertised but didn't serve", the reputation
606 // hazard that policy closes). Consulting the cache's coverage
607 // here would leak exactly that hit back in.
608 (Coverage::empty(), total_bytes)
609 } else {
610 match self.cache.coverage_sized(hash).await {
611 Ok((c, size)) => (c, total_bytes.or(size)),
612 Err(e) => {
613 tracing::warn!(
614 error = %e.display_chain(),
615 %hash,
616 "cache error deriving probe coverage; advertising none"
617 );
618 (Coverage::empty(), total_bytes)
619 }
620 }
621 };
622 let has_blob = !coverage.is_empty();
623 debug_assert_eq!(
624 has_blob,
625 !coverage.is_empty(),
626 "has_blob must be the coverage biconditional by construction"
627 );
628
629 // The node advertises its configured rate verbatim. The delivery floor
630 // is not a quote gate: a rate below the floor still sells and settles,
631 // and `PaymentPool.redeem` clamps only the vote-weight byte credit for
632 // sub-floor bytes (ADR 003 § Rate-floor enforcement).
633 let rate_per_mb = self.rate_per_mb;
634 let body = ProbeResponseBody {
635 hash: req.hash,
636 has_blob,
637 rate_per_mb,
638 timestamp_us: req.timestamp_us,
639 };
640 // EIP-712 secp256k1 slash_sig over the frozen signed set
641 // {hash, has_blob, rate_per_mb, timestamp_us} (ADR 014 §1). Mandatory
642 // and non-empty on every response — a signing failure fails the
643 // handler rather than emitting an unsigned response a requester MUST
644 // reject anyway.
645 let slash_data = ProbeSlashData {
646 hash: B256::from(req.hash),
647 has_blob,
648 rate_per_mb,
649 timestamp_us: req.timestamp_us,
650 };
651 let slash_sig = match slash_data.sign(self.eth_signer.as_ref(), &self.slash_domain) {
652 Ok(sig) => sig.as_bytes().to_vec(),
653 Err(e) => {
654 // Operator-actionable infra fault (key locked, remote/HSM
655 // signer offline). iroh logs a failed handler at warn level
656 // with only the top-level message, so log it at error level
657 // here before failing the handler (we must NOT emit an
658 // unsigned response, ADR 014 §1).
659 tracing::error!(
660 error = %e,
661 "probe slash_sig signing failed (eth signer unavailable?); \
662 failing probe"
663 );
664 return Err(anyhow::anyhow!("probe slash_sig signing failed: {e}"));
665 }
666 };
667
668 let resp = ProbeResponse { body, slash_sig };
669 // `total_bytes` and `coverage` are unsigned, so they ride in the trailing
670 // extension rather than the signed base (ADR 013 §Tier 1). Two-phase
671 // encode: a receiver that predates a future extension field stops at the
672 // end of the base.
673 let ext = ProbeResponseExt {
674 total_bytes,
675 coverage,
676 };
677
678 let payload = encode_probe_response(&resp, Some(&ext))
679 .map_err(|e| anyhow::anyhow!("probe encode failed: {e}"))?;
680 write_frame(&mut send, &payload)
681 .await
682 .map_err(|e| anyhow::anyhow!("probe response write failed: {e}"))?;
683 send.finish()
684 .map_err(|e| anyhow::anyhow!("probe stream finish failed: {e}"))?;
685
686 self.metrics.probe_request();
687 // Wait for the client's close so the response bytes are flushed to the
688 // peer, but cap the wait so an idle/malicious client can't hold the
689 // connection (and inflate active_connections) forever.
690 let _ = tokio::time::timeout(PROBE_CLOSE_TIMEOUT, conn.closed()).await;
691 conn.close(0u32.into(), b"probe-done");
692 Ok(())
693 }
694}
695
696impl ProtocolHandler for ProbeHandler {
697 async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
698 self.serve(connection)
699 .await
700 .map_err(|e| AcceptError::from_boxed(e.into()))
701 }
702}
703
704const fn frame_err_code(e: &FrameError) -> u32 {
705 // Match every variant explicitly so a future `#[non_exhaustive]` /
706 // new variant fails the build instead of being silently collapsed
707 // into `APP_ERR_MALFORMED_MESSAGE`.
708 match e {
709 FrameError::TooLarge(_) => APP_ERR_MESSAGE_TOO_LARGE,
710 // #577 M1: transport-level fault (short read, peer reset,
711 // mid-frame EOF) is NOT a protocol fault. Mirrors the
712 // timeout-path convention in `read_probe_request` — peers must
713 // not apply MALFORMED-class backoff/penalty for a dropped
714 // connection.
715 FrameError::Io(_) => APP_ERR_NO_ERROR,
716 FrameError::Varint | FrameError::Decode(_) => APP_ERR_MALFORMED_MESSAGE,
717 }
718}
719
720/// Decide whether an end-client (non-stake-lane) probe should be refused a
721/// probe hold to preserve reserved headroom for stake-lane requesters —
722/// registered operators issuing node-to-node cache-miss probes (#757, ADR
723/// 003 §Admission and Priority).
724///
725/// Returns `true` only when ALL of: a reservation is configured
726/// (`reserved > 0`), holds are enabled (`max_holds > 0`), current hold usage
727/// has reached the end-client ceiling (`slots_used >= max_holds - reserved`),
728/// and the requester is **not** in the stake lane. The last `reserved` slots
729/// are thereby kept available for the stake lane.
730///
731/// `is_stake_lane` is evaluated **lazily** and only after the three cheap
732/// guards above have passed, so callers can pass a closure that performs the
733/// staker-set lookup (an `RwLock` read + `NodeId` conversion) and have it
734/// skipped entirely on the uncongested common path (#757 review).
735///
736/// `reserved == 0` is the default and makes this an unconditional no-op so
737/// single-lane operators are unaffected. The `max_holds == 0` short-circuit
738/// keeps an intentional holds-disabled config (which `try_probe_hold` maps
739/// to [`ProbeHoldOutcome::HoldsDisabled`]) from being mis-attributed to a
740/// reservation refusal.
741///
742/// This is a **soft, best-effort** gate: `slots_used` is sampled before the
743/// atomic `try_probe_hold`, so a concurrent hold may still cross the
744/// boundary. That is acceptable for a priority heuristic — it is never a
745/// safety property (ADR 003 frames admission as implementation-defined).
746fn stake_lane_reserved_out(
747 reserved: usize,
748 max_holds: usize,
749 slots_used: usize,
750 is_stake_lane: impl FnOnce() -> bool,
751) -> bool {
752 reserved > 0
753 && max_holds > 0
754 && slots_used >= max_holds.saturating_sub(reserved)
755 && !is_stake_lane()
756}
757
758/// Error from the probe-request read path carrying the ADR 013 app error
759/// code the handler should propagate to the peer.
760struct ProbeReadError {
761 err: anyhow::Error,
762 app_code: u32,
763}
764
765/// Reads one framed `ProbeMessage::Request` from `recv`. On failure, also
766/// resets/stops the streams with the appropriate ADR 013 app error code so
767/// long-lived (future) multi-stream connections can keep running; the caller
768/// additionally closes the whole connection for probe's 1:1 topology.
769///
770/// The cognitive-complexity allowance reflects that splitting this further
771/// would spread the ADR 013 error-code mapping across multiple helpers,
772/// making it harder to audit against the spec.
773#[allow(clippy::cognitive_complexity)]
774async fn read_probe_request(
775 send: &mut SendStream,
776 recv: &mut RecvStream,
777) -> Result<decdn_protocol::message::ProbeRequest, ProbeReadError> {
778 let reset = |send: &mut SendStream, recv: &mut RecvStream, code: u32| {
779 let v = VarInt::from_u32(code);
780 // reset/stop may fail if stream already closed by peer; ignore.
781 let _ = send.reset(v);
782 let _ = recv.stop(v);
783 };
784
785 let frame = match tokio::time::timeout(PROBE_READ_TIMEOUT, read_frame(recv)).await {
786 Err(_) => {
787 // ADR 013 defines no timeout-specific code; use the named
788 // `APP_ERR_NO_ERROR` constant (same convention now applied
789 // to `FrameError::Io(_)` per #577 M1).
790 reset(send, recv, APP_ERR_NO_ERROR);
791 return Err(ProbeReadError {
792 err: anyhow::anyhow!("probe request timed out after {PROBE_READ_TIMEOUT:?}"),
793 app_code: APP_ERR_NO_ERROR,
794 });
795 }
796 Ok(Err(e)) => {
797 let app_code = frame_err_code(&e);
798 reset(send, recv, app_code);
799 return Err(ProbeReadError {
800 err: anyhow::anyhow!("probe frame read failed: {e}"),
801 app_code,
802 });
803 }
804 Ok(Ok(frame)) => frame,
805 };
806
807 match decode_message::<ProbeMessage>(&frame) {
808 Err(e) => {
809 // ADR 013: an unknown enum discriminant is a Tier-2
810 // graceful-evolution signal — close with UNSUPPORTED_MESSAGE
811 // (0x01), not MALFORMED_MESSAGE (0x03). A genuine postcard/varint
812 // parse fault (in-range discriminant, bad payload) stays MALFORMED.
813 let app_code = if is_unknown_variant::<ProbeMessage>(&frame) {
814 APP_ERR_UNSUPPORTED_MESSAGE
815 } else {
816 APP_ERR_MALFORMED_MESSAGE
817 };
818 reset(send, recv, app_code);
819 Err(ProbeReadError {
820 err: anyhow::anyhow!("probe decode failed: {e}"),
821 app_code,
822 })
823 }
824 Ok((ProbeMessage::Request(req), _rest)) => Ok(req),
825 Ok((ProbeMessage::Response(_), _)) => {
826 reset(send, recv, APP_ERR_UNSUPPORTED_MESSAGE);
827 Err(ProbeReadError {
828 err: anyhow::anyhow!("unexpected ProbeMessage::Response on server stream"),
829 app_code: APP_ERR_UNSUPPORTED_MESSAGE,
830 })
831 }
832 }
833}
834
835/// Fold the origin-held index into the store's `has_blob` decision (#1130).
836///
837/// If the store already backs `has_blob: true`, keep it. Otherwise, when the
838/// blob is servable from a configured origin (`origin_size` is `Some`),
839/// advertise it with the origin's size — cold origin content is discoverable
840/// on the first probe. Pure so the policy is unit-testable without a live
841/// connection.
842const fn fold_origin_held(
843 store_decision: (bool, Option<u64>),
844 origin_size: Option<u64>,
845) -> (bool, Option<u64>) {
846 match store_decision {
847 (true, bytes) => (true, bytes),
848 (false, _) => match origin_size {
849 Some(size) => (true, Some(size)),
850 None => (false, None),
851 },
852 }
853}
854
855#[cfg(test)]
856#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
857mod tests;