Durable realizations from developing dig-relay. Context, not a change diary (CLAUDE.md §4.5).
- The health sweep's clock must be
Instant, neverSystemTime(#1395). The sweep prunes a registration whennow - last_activity > liveness_deadline. If both readings are wall-clock (SystemTime), a host clock jump breaks it in BOTH directions: a forward NTP step (or DST) makesnowleap so EVERY live registration suddenly reads as stale → a mass false-prune of the whole peer set at once; a backward step freezes the deadline so dead records linger. The fix isliveness_now_secs()— monotonic elapsed-since-start from a process-startInstantin aOnceLock— used for BOTH thelast_activitystamp and the sweep'snow, so a decision is the difference of two monotonic readings. Crucially this is a SEPARATE clock from the wireRelayPeerInfo.last_seen/connected_at, which MUST stay real Unix timestamps (other peers read them as such) — don't "simplify" by making them one clock. - The ban list (#1396) keys on the IPv6 /64, same as every #1386 per-IP cap — so a ban is a /64-wide
hammer. The mitigation is NOT finer keying (a /64 is the smallest a site is reliably delegated;
finer lets an attacker walk addresses to dodge it) but a HIGH strike threshold (default 20) + a rolling
strike window (default 60s) + a short TTL (default 300s): only sustained, clustered abuse earns a ban,
so one misbehaving host in a shared /64 never bans the whole site on a stray cap-trip. Strikes are fed
from the EXISTING #1386 choke points (each cap-trip = one
record_strike); the ban only adds the accept-time refusal, it invents no new detection. In-memory only — a restart clears all bans.
- The grid cell IS the anonymity set, not a display convenience.
/map.jsonnever carries a per-peer coordinate — every located peer is snapped to a ~5° global grid cell (map::MAP_CELL_DEG) and only the cell's CENTROID + a COUNT is published. This means the smallest thing a client can ever learn about any one peer is "somewhere in a ~300-mile region, sharing it with N others" — there is no way to sharpen that by querying more, because the server never computed anything sharper to leak. Snapping happens withfloor(coord / cell_deg), notroundor truncation, so negative latitudes/longitudes and the antimeridian snap consistently (verified inmap::tests). - Colocated peers become a "column" for free. The globe just reads
countper cell and maps it topointAltitude(log-scaled) — the privacy aggregation and the "watch hotspots rise" visual are the SAME data structure; no separate stacking logic needed. - A permanent in-process cache, not a TTL cache.
geoip::locatecaches every resolved IP (hit or miss) for the life of the process (geoip::CACHE), because a peer's IP is realistically static across the relay's lifetime and the offline mmdb lookup is the only non-free part of building/map.jsonon its 5s refresh cadence. This is also a privacy reinforcement: a peer's IP is only ever handed to the geo database once, never on a schedule. maxminddb0.24'sReader::lookup<T>returnsResult<T, Error>, notResult<Option<T>, Error>— an absent record is a plainAddressNotFoundError, so.ok()alone (not.ok()?.ok()??) collapses "not found" intoNone. Easy to over-apply?here since so much of the surrounding code chainsOption.r#"..."#raw strings break the instant the content contains a literal"#— a CSS hex color written"#8ab4ff"inside anr#"...HTML..."#Rust raw string prematurely closes it. Any HTML/CSS template containing a quoted#colorhexneeds one more hash (r##"..."##) than the dashboard's existingDASHBOARD_HTML` const, which happens not to quote a hex color anywhere.
- IPv6 is keyed on the /64 prefix, not the full /128. A single IPv6 assignment is typically a /64
(or larger); an attacker on one delegated /64 owns 2^64 addresses, so per-/128 caps would be trivial
to sidestep by walking addresses. Keying the per-IP connection/registration caps on the /64 prefix
(low 64 bits zeroed,
limits::ip_key) makes the caps bite per real source. IPv4 stays /32, and an IPv4-mapped IPv6 source collapses to its IPv4 form so a dual-stack client can't earn two budgets — the same.to_canonical()normalization the STUN limiter already uses. - Breach response differs by dimension on purpose. A per-CONNECTION message/byte/cumulative breach
DISCONNECTS (closes the socket): a connection flooding far past the generous defaults is not a
well-behaved peer to slow down but an abusive one to shed, and disconnecting reclaims the resource
immediately. A per-IP registration breach REFUSES with
RegisterAck{success:false}+Error{code:7, RATE_LIMITED}(mirroring the CAPACITY/ID_IN_USE refusal shape) rather than disconnecting, because the connection itself may be legitimate — only the register attempt is over-budget — and a distinct code lets the client tell a per-source throttle from the global cap. - The hot path takes no shared lock. The per-connection limiter (
PerConnLimiter) is TASK-LOCAL (owned by the one connection task), so the per-frame check never contends. The sharedAbusePolicy(astd::sync::Mutexover small per-IP maps) is touched only at the cold once-per-lifecycle choke points — connection open/close and registration — where an O(1) non-async critical section is free. Usingstd::sync::Mutex(never tokio's) is safe precisely because no.awaitcrosses it. - RAII slots mirror
OpenConnectionGuard. Per-IP connection and concurrent-registration counts are released onDrop(ConnSlot/RegSlot), so a count can never leak on any exit path — normal close, ws error, timeout, health-sweep prune, or panic — exactly like the existing open-connection guard.