Skip to main content

dig_stun/
observe.rs

1//! The peer-observation responder role (`SPEC.md` §6) — how a directly-reachable DIG node answers
2//! `dig.getObservedAddress` for a peer, and the abuse bounds on doing so.
3//!
4//! [`observe`] is a PURE decision: no socket, no listener, no spawned task, no clock. A DIG node
5//! MUST NOT open a UDP STUN listener to serve peers (`SPEC.md` §6.1) — this module never creates
6//! one; the caller owns the authenticated mTLS peer session this rides on and calls [`observe`] once
7//! per request.
8
9use std::collections::HashMap;
10use std::net::{IpAddr, SocketAddr};
11
12use crate::scope::{fold_ip, scope_of, Scope};
13
14/// Which side accepted the TCP connection this observation rides on (`SPEC.md` §6.3).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Direction {
17    /// This node accepted the connection — `remote` is a genuine observation of the peer's
18    /// traffic. Maps from `dig_nat::TraversalKind` at the call site: every kind other than a relayed
19    /// circuit is [`Path::Direct`] (`SPEC.md` §6.3).
20    Inbound,
21    /// This node dialled out — `remote` is the address it CHOSE to dial, never something it
22    /// observed.
23    Outbound,
24}
25
26/// Whether the session rides directly on the wire or through a relay circuit (`SPEC.md` §6.3).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Path {
29    /// A direct transport connection between the two peers.
30    Direct,
31    /// A relayed circuit. `remote` would be the relay's own endpoint (or an unspecified wildcard),
32    /// never the requester's address.
33    Relayed,
34}
35
36/// The facts about a session [`observe`] needs in order to decide whether to answer it.
37///
38/// `#[non_exhaustive]`: an additive field is a patch release for consumers, who construct this via
39/// [`SessionMeta::new`] rather than a struct literal (`SPEC.md` §12).
40#[non_exhaustive]
41#[derive(Debug, Clone, Copy)]
42pub struct SessionMeta {
43    /// Which side accepted the connection.
44    pub direction: Direction,
45    /// Whether the connection is direct or relayed.
46    pub path: Path,
47    /// The remote address this node observed the connection arrive from.
48    pub remote: SocketAddr,
49}
50
51impl SessionMeta {
52    /// Construct a [`SessionMeta`] from its three facts.
53    pub fn new(direction: Direction, path: Path, remote: SocketAddr) -> Self {
54        SessionMeta {
55            direction,
56            path,
57            remote,
58        }
59    }
60}
61
62/// Why [`observe`] declined to answer (`SPEC.md` §6.3). Exhaustive: a new refusal reason is a
63/// breaking change for a consumer matching on this exhaustively.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Refusal {
66    /// This node dialled out on this connection; `remote` is not an observation of anything.
67    Outbound,
68    /// The session rides a relayed circuit; `remote` is the relay's endpoint, not the requester's.
69    Relayed,
70    /// The observed address can never be a legitimate dial target
71    /// ([`Scope::NeverDialable`] — `SPEC.md` §5).
72    Unusable,
73    /// The caller's [`ObserveLimiter`] budget for this session, source, or globally is exhausted.
74    /// Never produced by [`observe`] itself — the caller applies the limiter separately and reports
75    /// this reason instead of silently dropping the request (`SPEC.md` §6.4).
76    RateLimited,
77}
78
79/// The peer-observation decision (`SPEC.md` §6.3): whether, and at what address, to tell a peer
80/// what its own traffic looks like from here.
81///
82/// Answers only when ALL of: the connection is [`Direction::Inbound`] (else [`Refusal::Outbound`]);
83/// the path is [`Path::Direct`] (else [`Refusal::Relayed`]); and `remote`'s [`Scope`] is not
84/// [`Scope::NeverDialable`] (else [`Refusal::Unusable`] — a [`Scope::PrivateScope`] remote IS
85/// answered, since the requester may be a LAN peer, and what it does with a private reading is the
86/// requester's decision under [`crate::establish`]).
87///
88/// The answer is `meta.remote` with an IPv4-mapped IPv6 address folded to IPv4
89/// (`fold_ip`), so a requester that connected over IPv4 is never handed a 16-byte
90/// address. The port is preserved unchanged — it is informational only for the CALLER to interpret
91/// (`SPEC.md` §6.2), never something this function judges.
92pub fn observe(meta: &SessionMeta) -> Result<SocketAddr, Refusal> {
93    if meta.direction != Direction::Inbound {
94        return Err(Refusal::Outbound);
95    }
96    if meta.path != Path::Direct {
97        return Err(Refusal::Relayed);
98    }
99    if scope_of(meta.remote) == Scope::NeverDialable {
100        return Err(Refusal::Unusable);
101    }
102    Ok(SocketAddr::new(
103        fold_ip(meta.remote.ip()),
104        meta.remote.port(),
105    ))
106}
107
108/// Default: how many `dig.getObservedAddress` answers one authenticated session may receive per
109/// rolling minute — and, independently, how many a single source IP may receive per rolling minute.
110/// These are TWO separately-tracked budgets (a session map and a source-IP map, each evaluated on
111/// its own), not one: `ObserveLimiter::new`'s single `per_session_and_source_per_minute` argument
112/// sets the SAME numeric capacity for both, because a session is usually pinned to one IP, but
113/// CGNAT can put many sessions behind one — so tracking them independently, even at a shared rate,
114/// catches an abuser hiding behind either grouping (`SPEC.md` §6.4).
115pub const OBSERVE_PER_SESSION_PER_MINUTE: u32 = 6;
116/// Default: how many answers this node may send in total per second, across every session.
117pub const OBSERVE_GLOBAL_PER_SECOND: u32 = 64;
118/// Upper bound on distinct sessions, and separately on distinct source IPs, tracked at once. Past
119/// this bound the least-recently-seen entry is evicted to make room for a new one, so neither map
120/// can be grown without bound by a flood of distinct callers.
121pub const MAX_TRACKED_SOURCES: usize = 4096;
122
123const SESSION_SOURCE_WINDOW_MS: u64 = 60_000;
124const GLOBAL_WINDOW_MS: u64 = 1_000;
125
126/// A whole-token bucket refilled to `capacity` once per fixed window. The same shape as
127/// `dig-relay`'s STUN reflector limiter (`dig-relay/src/stun.rs`) — small enough that copying the
128/// algorithm here is cheaper and safer than a cross-license dependency on a GPL-2.0 application.
129#[derive(Debug, Clone, Copy)]
130struct TokenBucket {
131    tokens: u32,
132    window: u64,
133    last_seen_ms: u64,
134}
135
136impl TokenBucket {
137    fn new(capacity: u32, now_ms: u64, window_ms: u64) -> Self {
138        TokenBucket {
139            tokens: capacity,
140            window: now_ms / window_ms,
141            last_seen_ms: now_ms,
142        }
143    }
144
145    /// Whether a token is available in the window containing `now_ms`, WITHOUT spending it.
146    fn peek(&self, now_ms: u64, window_ms: u64) -> bool {
147        now_ms / window_ms != self.window || self.tokens > 0
148    }
149
150    /// Spend one token, refilling to `capacity` first if `now_ms` has entered a new window. The
151    /// caller must already have confirmed availability via [`Self::peek`] for this same `now_ms`.
152    fn spend(&mut self, capacity: u32, now_ms: u64, window_ms: u64) {
153        let window = now_ms / window_ms;
154        if window != self.window {
155            self.window = window;
156            self.tokens = capacity;
157        }
158        self.tokens = self.tokens.saturating_sub(1);
159        self.last_seen_ms = now_ms;
160    }
161}
162
163/// Get or create the bucket for `key`, evicting the least-recently-seen entry first when `map` is
164/// at [`MAX_TRACKED_SOURCES`] and `key` is not already tracked.
165fn bucket_for<'m, K: std::hash::Hash + Eq + Clone>(
166    map: &'m mut HashMap<K, TokenBucket>,
167    key: &K,
168    capacity: u32,
169    now_ms: u64,
170    window_ms: u64,
171) -> &'m mut TokenBucket {
172    if !map.contains_key(key) && map.len() >= MAX_TRACKED_SOURCES {
173        if let Some(victim) = map
174            .iter()
175            .min_by_key(|(_, b)| b.last_seen_ms)
176            .map(|(k, _)| k.clone())
177        {
178            map.remove(&victim);
179        }
180    }
181    map.entry(key.clone())
182        .or_insert_with(|| TokenBucket::new(capacity, now_ms, window_ms))
183}
184
185/// Abuse bounds for the peer observation responder (`SPEC.md` §6.4): an independent per-session
186/// budget, an independent per-source-IP budget, and a global budget checked only once both narrower
187/// budgets already permit — so one abuser can never drain the budget meant for everyone else.
188///
189/// **Three budgets tracked, from only TWO numbers.** The session budget and the source-IP budget
190/// are separate maps, each consulted and spent independently — but [`ObserveLimiter::new`] takes a
191/// single capacity that sizes BOTH of them identically, because a session is usually pinned to one
192/// source IP and giving them independent numbers would add a knob nobody has a reason to turn
193/// differently. If a real deployment ever needs the two to diverge, that is a signature change, not
194/// a silent one: see the constructor's own doc for how to request it.
195///
196/// Keys are TRANSPORT facts: the authenticated session's peer_id, and the accepted connection's
197/// source IP (folded per `SPEC.md` §5.3). `dig.getObservedAddress` takes no request parameters, so
198/// there is no payload for either key to come from.
199pub struct ObserveLimiter {
200    /// The SAME capacity applied to both the per-session map and the per-source-IP map.
201    session_and_source_capacity: u32,
202    global_capacity: u32,
203    per_session: HashMap<String, TokenBucket>,
204    per_source: HashMap<IpAddr, TokenBucket>,
205    global: TokenBucket,
206}
207
208impl ObserveLimiter {
209    /// `per_session_and_source_per_minute` is ONE number that sizes TWO independent budgets: how
210    /// many answers a single authenticated session may receive per rolling minute, AND, separately,
211    /// how many a single source IP may receive per rolling minute — the same capacity, two
212    /// different keys, each with its own map and its own refill window (`SPEC.md` §6.4). This is
213    /// deliberate, not a simplification that lost a parameter: a session is usually pinned to one
214    /// source IP, but CGNAT can put many sessions behind one, so the two maps still have to be
215    /// consulted independently even though they share a rate.
216    ///
217    /// `global_per_second` sizes the third, shared budget.
218    ///
219    /// A `0` capacity in either argument denies every request in that dimension — a bucket that
220    /// starts and refills to zero tokens can never be spent from.
221    pub fn new(per_session_and_source_per_minute: u32, global_per_second: u32) -> Self {
222        ObserveLimiter {
223            session_and_source_capacity: per_session_and_source_per_minute,
224            global_capacity: global_per_second,
225            per_session: HashMap::new(),
226            per_source: HashMap::new(),
227            global: TokenBucket::new(global_per_second, 0, GLOBAL_WINDOW_MS),
228        }
229    }
230
231    /// Whether `session` (the requester's authenticated peer_id) may receive another observation
232    /// answer right now, given the connection's transport-observed `source` IP.
233    ///
234    /// Checks the per-session and per-source budgets BEFORE the global one, and — only when all
235    /// three permit — spends one token in each. A request refused by the narrower budgets never
236    /// touches the global one, so a single abusive session or source cannot drain the budget shared
237    /// by every other caller (`SPEC.md` §6.4).
238    pub fn allow(&mut self, session: &str, source: IpAddr, now_ms: u64) -> bool {
239        let source = fold_ip(source);
240        let session_key = session.to_string();
241        let capacity = self.session_and_source_capacity;
242
243        let session_bucket = bucket_for(
244            &mut self.per_session,
245            &session_key,
246            capacity,
247            now_ms,
248            SESSION_SOURCE_WINDOW_MS,
249        );
250        if !session_bucket.peek(now_ms, SESSION_SOURCE_WINDOW_MS) {
251            // Touch last_seen so an actively-asking (even if throttled) session isn't the one
252            // evicted first the next time this map is full.
253            session_bucket.last_seen_ms = now_ms;
254            return false;
255        }
256
257        let source_bucket = bucket_for(
258            &mut self.per_source,
259            &source,
260            capacity,
261            now_ms,
262            SESSION_SOURCE_WINDOW_MS,
263        );
264        if !source_bucket.peek(now_ms, SESSION_SOURCE_WINDOW_MS) {
265            source_bucket.last_seen_ms = now_ms;
266            return false;
267        }
268
269        if !self.global.peek(now_ms, GLOBAL_WINDOW_MS) {
270            return false;
271        }
272
273        // All three permit: commit a token in each.
274        bucket_for(
275            &mut self.per_session,
276            &session_key,
277            capacity,
278            now_ms,
279            SESSION_SOURCE_WINDOW_MS,
280        )
281        .spend(capacity, now_ms, SESSION_SOURCE_WINDOW_MS);
282        bucket_for(
283            &mut self.per_source,
284            &source,
285            capacity,
286            now_ms,
287            SESSION_SOURCE_WINDOW_MS,
288        )
289        .spend(capacity, now_ms, SESSION_SOURCE_WINDOW_MS);
290        self.global
291            .spend(self.global_capacity, now_ms, GLOBAL_WINDOW_MS);
292        true
293    }
294}
295
296#[cfg(test)]
297mod bounded_map_tests {
298    //! The LRU bound (`SPEC.md` §11 item 7) needs `per_session`/`per_source`'s private lengths, so
299    //! this lives beside the struct rather than in `tests/observe.rs`.
300    use super::*;
301    use std::net::Ipv4Addr;
302
303    #[test]
304    fn per_session_and_per_source_maps_stay_bounded_past_max_tracked_sources() {
305        let mut limiter =
306            ObserveLimiter::new(OBSERVE_PER_SESSION_PER_MINUTE, OBSERVE_GLOBAL_PER_SECOND);
307
308        // Feed many more distinct (session, source) pairs than MAX_TRACKED_SOURCES; neither map
309        // may exceed that bound no matter how many distinct callers are seen.
310        for i in 0..(MAX_TRACKED_SOURCES as u64 + 5000) {
311            let src = IpAddr::V4(Ipv4Addr::new(
312                ((i >> 24) & 0xff) as u8,
313                ((i >> 16) & 0xff) as u8,
314                ((i >> 8) & 0xff) as u8,
315                (i & 0xff) as u8,
316            ));
317            limiter.allow(&format!("peer-{i}"), src, i);
318        }
319
320        assert!(
321            limiter.per_session.len() <= MAX_TRACKED_SOURCES,
322            "per-session map must stay bounded"
323        );
324        assert!(
325            limiter.per_source.len() <= MAX_TRACKED_SOURCES,
326            "per-source map must stay bounded"
327        );
328    }
329}