decdn_node/handlers/probe_rate_limit.rs
1//! Three-layer token-bucket rate limiter for `cdn/probe/v1` inbound traffic
2//! (ADR 005 §Probe rate limiting).
3//!
4//! A thin probe-flavoured wrapper over the shared
5//! [`crate::rate_limit::ThreeLayerRateLimiter`] engine: it pins the
6//! probe-specific operator-visible metric names (via a private metrics sink)
7//! and re-exports the config/reject types under probe names. The layering and
8//! cheapest-first ordering (global → per-IP → per-peer) live in the shared
9//! engine — see its module docs.
10//!
11//! ## Relationship to [`crate::dispatch::ConnectionLimiter`]
12//!
13//! The probe handler runs **both** limiters: it still acquires a
14//! `ConnectionLimiter` permit (the global concurrency semaphore + the shared
15//! per-source IP bucket that gates every ALPN) and then calls
16//! [`ProbeRateLimiter::check`] for the ADR 005 three-layer token buckets. The
17//! per-source IP layer and this limiter's per-IP layer therefore both charge a
18//! token per probe — a benign overlap: the ADR 005 probe per-IP default
19//! (50/s) is stricter than the `security.per_source_*` default (100/s), so the
20//! probe layer dominates, and the per-peer (`NodeId`) layer ADR 005 mandates
21//! exists **only** here. `cdn/probe/v1` is one connection / one stream / one
22//! probe, so a single `check` per accepted connection is the whole budget.
23
24use std::sync::Arc;
25
26use crate::metrics::Metrics;
27use crate::rate_limit::{
28 RateLimitConfig, RateLimitMetricsSink, RejectLayer, ThreeLayerRateLimiter,
29};
30
31/// Resolved probe rate-limit configuration. See ADR 005 §Probe rate limiting.
32/// The ADR 005 defaults (per-peer 5/5, per-IP 50/200, global 1000/2000) are
33/// supplied by the config resolver, not by [`Default`] (which returns the DHT
34/// values shared across the engine).
35pub type ProbeRateLimitConfig = RateLimitConfig;
36
37/// Layer that triggered a `cdn/probe/v1` rejection. Alias of the shared
38/// [`RejectLayer`]; the `per_peer`/`per_ip`/`global` label strings are stable.
39pub type ProbeRejectLayer = RejectLayer;
40
41/// Routes shared-limiter metric events to the probe operator-visible counters
42/// (`decdn_probe_rate_limit_*`): one unlabeled Counter per layer, per the
43/// sibling-counter convention settled in #1475 and recorded in
44/// `adr/appendix-observability.md` § Reason splits. Operators recover the
45/// rolled-up rate with
46/// `sum(rate({__name__=~"decdn_probe_rate_limit_rejected_(per_peer|per_ip|global)_total"}[1m]))`.
47///
48/// **This is a choice, not a backend limitation.** `iroh_metrics` supports
49/// per-field labels:
50/// `DecdnMetrics::probe_hold_unavailable` and `DecdnMetrics::streams_active` are
51/// both `Family<L, M>` (private fields; see
52/// [`crate::metrics::Metrics::probe_hold_unavailable`] for the accessor that
53/// uses one). Each layer here has an unrelated remedy (one abusive
54/// peer, one abusive host, aggregate load), so no alert spans the family and a
55/// shared label would buy nothing. The appendix registry carries this trio of
56/// unlabelled counters (ADR 005 § Observability).
57struct ProbeRateLimitMetrics(Arc<Metrics>);
58
59impl RateLimitMetricsSink for ProbeRateLimitMetrics {
60 fn rejected(&self, layer: RejectLayer) {
61 match layer {
62 RejectLayer::Global => self.0.probe_rate_limit_rejected_global(),
63 RejectLayer::PerIp => self.0.probe_rate_limit_rejected_per_ip(),
64 RejectLayer::PerPeer => self.0.probe_rate_limit_rejected_per_peer(),
65 }
66 }
67
68 fn prune_sweep_per_ip(&self) {
69 self.0.probe_rate_limit_prune_sweep_per_ip();
70 }
71
72 fn prune_sweep_per_peer(&self) {
73 self.0.probe_rate_limit_prune_sweep_per_peer();
74 }
75
76 fn set_tracked_per_ip(&self, n: usize) {
77 self.0.probe_rate_limit_tracked_per_ip_set(n);
78 }
79
80 fn set_tracked_per_peer(&self, n: usize) {
81 self.0.probe_rate_limit_tracked_per_peer_set(n);
82 }
83}
84
85/// Three-layer rate limiter for inbound `cdn/probe/v1` requests. Newtype over
86/// the shared [`ThreeLayerRateLimiter`] that injects the probe metrics sink.
87#[allow(missing_debug_implementations)]
88pub struct ProbeRateLimiter(ThreeLayerRateLimiter);
89
90impl ProbeRateLimiter {
91 /// Build a limiter from the resolved probe config snapshot.
92 #[must_use]
93 pub fn new(cfg: &ProbeRateLimitConfig, metrics: Arc<Metrics>) -> Self {
94 Self(ThreeLayerRateLimiter::new(
95 cfg,
96 Arc::new(ProbeRateLimitMetrics(metrics)),
97 ))
98 }
99
100 /// Build a limiter straight from the resolved `[probe.rate_limit]` section.
101 ///
102 /// Prefer this over [`Self::new`] at wiring sites. `ProbeRateLimitConfig`
103 /// is an alias of the shared [`RateLimitConfig`], so `new` accepts a config
104 /// mapped from *either* protocol's resolved section — building the probe
105 /// limiter from `[dht.rate_limit]` type-checks fine and would quietly
106 /// quadruple the per-peer probe cap (ADR 022's 20/sec in place of ADR 005's
107 /// 5/sec). Taking `&ResolvedProbe` makes that a type error instead (#1457).
108 #[must_use]
109 pub fn from_resolved(
110 resolved: &decdn_common::config::ResolvedProbe,
111 metrics: Arc<Metrics>,
112 ) -> Self {
113 Self::new(&RateLimitConfig::from(resolved), metrics)
114 }
115
116 /// Try to admit one inbound probe. See [`ThreeLayerRateLimiter::check`].
117 pub fn check(
118 &self,
119 peer_node_id: &crate::dht::routing::NodeId,
120 peer_ip: Option<std::net::IpAddr>,
121 ) -> Result<(), ProbeRejectLayer> {
122 self.0.check(peer_node_id, peer_ip)
123 }
124
125 /// Periodic GC sweep for the per-IP keyed map (#645).
126 #[must_use]
127 pub fn gc_per_ip(&self) -> Option<(usize, usize)> {
128 self.0.gc_per_ip()
129 }
130
131 /// Periodic GC sweep for the per-peer keyed map (#645).
132 #[must_use]
133 pub fn gc_per_peer(&self) -> Option<(usize, usize)> {
134 self.0.gc_per_peer()
135 }
136
137 /// Current tracked-key count for the per-IP keyed map (#645).
138 #[must_use]
139 pub fn per_ip_tracked(&self) -> usize {
140 self.0.per_ip_tracked()
141 }
142
143 /// Current tracked-key count for the per-peer keyed map (#645).
144 #[must_use]
145 pub fn per_peer_tracked(&self) -> usize {
146 self.0.per_peer_tracked()
147 }
148}
149
150#[cfg(test)]
151#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
152mod tests;