Skip to main content

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;