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}