Skip to main content

dig_nat/
relay.rs

1//! Relay client — the LAST-RESORT transport + the node's persistent reachability channel.
2//!
3//! Relocated + generalized from `dig-node`'s `relay.rs`. Two responsibilities:
4//!
5//! 1. **Persistent reservation** ([`run_relay_connection`]) — a DIG Node behind NAT can't accept
6//!    inbound dials, so it holds a CONSTANT registered connection with a publicly-reachable relay
7//!    (default [`dig_constants::DIG_RELAY_URL`], override `DIG_RELAY_URL`, opt out with
8//!    `DIG_RELAY_URL=off`). This is the reachability channel other peers reach it through and the
9//!    rendezvous for relay-coordinated hole-punch.
10//! 2. **Relayed transport** — when every NAT-traversal method fails, peer traffic is tunnelled
11//!    THROUGH the relay (RLY-002 `relay_message`). This is the last resort in the traversal order.
12//!
13//! **Graceful-fallback guarantees (baked in):** the reservation loop NEVER blocks startup, NEVER
14//! panics/exits, and NEVER hot-loops error-spam — failures log ONCE per state change (a transition
15//! into `Disconnected`), and every retry sleeps a bounded, capped-exponential backoff. If the relay
16//! is unreachable the node keeps serving indefinitely; the task just keeps retrying in the
17//! background. State is published through [`RelayStatus`] (a cheap atomic snapshot) as one of four
18//! [`RelayState`]s and surfaced verbatim to a `control.relayStatus`-style RPC / `/health`.
19
20use std::collections::{HashMap, HashSet};
21use std::net::{IpAddr, SocketAddr};
22use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26use dig_ip::{CandidateSource, DialConfig, LocalStack, PeerCandidates};
27use futures_util::{SinkExt, StreamExt};
28use tokio::net::TcpStream;
29use tokio::sync::mpsc;
30use tokio_tungstenite::tungstenite::Message;
31use tokio_tungstenite::{client_async_tls_with_config, MaybeTlsStream, WebSocketStream};
32
33use crate::wire::{RelayMessage, RelayPeerInfo};
34
35/// Default network id a node registers under (matches dig-gossip `DEFAULT_INTRODUCER_NETWORK_ID`
36/// and dig-node's `DEFAULT_NETWORK_ID`).
37pub const DEFAULT_NETWORK_ID: &str = "DIG_MAINNET";
38
39/// Relay protocol version the node advertises in `Register` (RLY-001).
40pub const RELAY_PROTOCOL_VERSION: u32 = 1;
41
42/// Base reconnect delay (dig-gossip `RelayConfig::reconnect_delay_secs` = 5).
43const BASE_BACKOFF_SECS: u64 = 5;
44/// Cap on the exponential backoff so a long outage doesn't push the retry interval to hours.
45const MAX_BACKOFF_SECS: u64 = 300;
46/// Keepalive ping period (RLY-006; dig-gossip `PING_INTERVAL_SECS` = 30).
47const PING_INTERVAL_SECS: u64 = 30;
48/// How often the held reservation re-pulls the relay peer list (RLY-005 `GetPeers`) over the SAME
49/// persistent socket, so a peer that registers AFTER this node — or one missed on the first pull —
50/// is still discovered without ever reopening the connection (the connect-leg fix).
51const DISCOVERY_INTERVAL_SECS: u64 = 60;
52
53/// Hard cap on the peers retained in the discovered set ([`RelayStatus::known_peers`]).
54///
55/// SECURITY: the relay is an UNTRUSTED intermediary. A hostile/compromised relay can stream an
56/// unbounded flood of `PeerConnected` frames — or a single oversized `Peers` frame — with distinct
57/// fabricated `peer_id`s, so an uncapped set is a memory-exhaustion DoS. 1024 is far more than any
58/// honest relay reports for one network's live reservations (the set is folded into a peer pool that
59/// itself selects a small working subset), yet small enough that the worst case is bounded, cheap
60/// memory. Beyond the cap, further distinct peers are DROPPED rather than grown.
61pub const MAX_KNOWN_PEERS: usize = 1024;
62
63/// Hard cap on the byte length of a single RLY-002 relayed-transport payload (both directions).
64///
65/// SECURITY / backpressure: the relay is UNTRUSTED and a peer reached over relayed transport is the
66/// last-resort TURN path, so an oversized frame is refused rather than buffered — an outbound `send`
67/// larger than this errors, and an inbound frame larger than this is dropped. 1 MiB comfortably
68/// holds a sealed gossip message (NC-1 ciphertext) while bounding the worst-case per-frame memory.
69pub const MAX_RELAY_PAYLOAD: usize = 1 << 20;
70
71/// Bounded inbound capacity for one open [`RelayTunnel`]. A full channel applies backpressure — the
72/// reservation loop `try_send`s inbound relayed bytes and DROPS the frame when the consumer is not
73/// keeping up, so a hostile relay flooding one tunnel cannot exhaust memory (matches the
74/// [`MAX_KNOWN_PEERS`] bounded-set philosophy). The RLY-002 `seq` lets the consumer detect the gap.
75const RELAY_TUNNEL_INBOUND_CAP: usize = 256;
76
77/// Upper bound on concurrently-registered relay tunnels (outbound dial + inbound accept combined)
78/// before the RESPONDER path refuses to create a new inbound circuit.
79///
80/// SECURITY: the relay is UNTRUSTED. When the accept path ([`RelayStatus::enable_accept`]) is on, an
81/// inbound RLY-002 frame from an unknown peer creates a server-role tunnel + surfaces an accept — so
82/// an uncapped accept lets a hostile relay flood distinct fabricated `from` ids to spawn unbounded
83/// tunnels/accept-tasks (a memory/task-exhaustion DoS). Beyond this cap the introduced circuit is
84/// DROPPED rather than accepted. 256 is far more concurrent relayed peers than the last-resort tier
85/// ever legitimately carries, yet bounds the worst case to cheap, bounded memory.
86pub const MAX_RELAY_TUNNELS: usize = 256;
87
88/// Bounded capacity of the inbound-accept channel ([`RelayStatus::enable_accept`]). A full channel
89/// means the consumer is not accepting introduced circuits fast enough; the newest is dropped
90/// (bounded backpressure), never queued unboundedly.
91const INBOUND_ACCEPT_CAP: usize = 64;
92
93/// How long a registered relay circuit may sit with NO inbound frame before a fresh outbound dial to
94/// that peer is allowed to replace it (#1871).
95///
96/// The RLY-002 non-clobber guard (#1536) exists to resolve a GLARE — two peers dialing each other at
97/// once — and that race is decided within one handshake round trip. A circuit silent for far longer
98/// than any handshake is not a glare; it is a registration whose mTLS session never came up, or one
99/// that has since died without its [`RelayTunnel`] being dropped (a stuck accept task, a peer that
100/// vanished mid-handshake). Left immortal, such an entry PERMANENTLY suppresses the last-resort tier
101/// for that peer — precisely the connectivity loss the relay exists to prevent. 30s is orders of
102/// magnitude above a handshake RTT, so a real glare is never mistaken for a phantom.
103///
104/// THE ASSUMPTION THIS WINDOW RESTS ON, stated because the guard has a SECOND job. Discriminating a
105/// glare is exact: that race resolves within one handshake round trip, far inside any plausible
106/// window. But the same guard also protects an ESTABLISHED session ("the existing circuit IS the
107/// connection"), and for that job "silent for 30s" means dead only if a live relayed session is
108/// guaranteed inbound traffic at least that often. NOTHING IN THIS CRATE GUARANTEES THAT — a consumer
109/// that can leave a relayed session genuinely quiet for longer must carry its own keepalive over the
110/// tunnel (recorded normatively in `SPEC.md`). If that is violated, a quiet-but-live circuit loses its
111/// key to a fresh dial, the displaced session's id-matched `close_tunnel` never fires, and inbound
112/// frames route into the new entry — #1536's double-session harm reached by a new trigger.
113///
114/// NOT related to [`PING_INTERVAL_SECS`], which is also 30 — a COINCIDENCE, and a misleading one. That
115/// is the node-to-RELAY reservation keepalive (RLY-006); it never reaches
116/// [`route_relayed`](RelayStatus::route_relayed), so it never refreshes a circuit's `last_activity`.
117/// Do not "align" the two, and do not read one as justifying the other.
118///
119/// The window also does NOT bound a relay that INJECTS frames: `last_activity` is stamped on any
120/// inbound frame for the key, ahead of routing and validation, so an injecting relay can hold a
121/// phantom "live" forever. Stamping later does not help (an injected frame reaches a live sink too);
122/// see the injected-ClientHello caveat in `SPEC.md`.
123const STALE_CIRCUIT_IDLE: Duration = Duration::from_secs(30);
124
125/// The mTLS role a locally-registered [`RelayTunnel`] runs — the discriminator that resolves the
126/// GLARE / simultaneous-mutual-dial case (#1536). A relay circuit needs exactly ONE mTLS client + ONE
127/// mTLS server; when two NAT'd peers fall to the relay tier and dial EACH OTHER at the same time (the
128/// common two-NAT'd-peer flywheel case), both open a `Client` tunnel and both send a ClientHello —
129/// each ClientHello would route into the OTHER side's client session (double-ClientHello deadlock).
130/// Tagging the tunnel with its role lets [`route_relayed`](RelayStatus::route_relayed) detect that a
131/// ClientHello arrived on a tunnel where WE are also the client (the glare signature) and apply the
132/// deterministic tie-break instead of feeding it to our doomed client.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum TunnelRole {
135    /// WE initiated the dial — running [`PeerSession::client`](crate::mux::PeerSession::client) over
136    /// this tunnel; inbound frames are the peer's ServerHello / server-side records.
137    Client,
138    /// WE accepted an introduced circuit — running [`PeerSession::server`](crate::mux::PeerSession::server)
139    /// over this tunnel; inbound frames are the dialing peer's client-side records.
140    Server,
141}
142
143/// A registered relayed tunnel: the inbound sink frames are routed into, the mTLS [`TunnelRole`] we
144/// run over it (for the #1536 glare tie-break), and a monotonic `id` distinguishing this registration
145/// from a later one on the SAME peer key. The id matters when the glare tie-break REPLACES our client
146/// tunnel with a server tunnel under the same `from` key: the old client [`RelayTunnel`]'s `Drop`
147/// (fired when its doomed dial fails) must not deregister the NEW server entry, so `close_tunnel` only
148/// removes when the stored id still matches.
149#[derive(Debug)]
150struct TunnelEntry {
151    sink: mpsc::Sender<Vec<u8>>,
152    role: TunnelRole,
153    id: u64,
154    /// When this circuit last showed life — stamped at registration and refreshed on every inbound
155    /// frame routed into it. Read by [`RelayStatus::open_tunnel`] to tell a live circuit from a
156    /// phantom (#1871); see [`STALE_CIRCUIT_IDLE`].
157    last_activity: Instant,
158}
159
160/// Whether `payload` begins with a TLS handshake record whose first message is a ClientHello — a TLS
161/// record has content-type `0x16` (handshake) at byte 0 and the handshake message type at byte 5
162/// (`0x01` = ClientHello, `0x02` = ServerHello). A rustls client ships its ClientHello flight as the
163/// first `poll_write`, so the first relayed frame from a fresh dialer matches this.
164///
165/// This is the relay layer's DIRECTION discriminator, and it decides the mTLS role in both directions:
166/// a ClientHello means the remote is the circuit's client, so we are its server; anything else is not
167/// the start of an inbound circuit at all. [`RelayStatus::route_relayed`] uses it to tell a peer's
168/// GLARE ClientHello (a competing simultaneous dial) from the ServerHello / app records expected where
169/// we are the client (#1536), and [`RelayStatus::accept_introduced`] uses it to refuse to manufacture
170/// a server-role circuit out of a frame no dialer sent (#1761).
171fn is_tls_client_hello(payload: &[u8]) -> bool {
172    payload.len() >= 6 && payload[0] == 0x16 && payload[5] == 0x01
173}
174
175/// Relay error codes that invalidate THIS node's own reservation, mirroring `dig-relay`'s
176/// `errcode` catalogue. Kept as an explicit list so the two stay conformance-checkable.
177///
178/// - `1 NOT_REGISTERED` — the relay does not consider us registered, so the reservation is gone.
179/// - `4 CAPACITY`, `5 ID_IN_USE`, `6 IDENTITY_MISMATCH`, `7 RATE_LIMITED` — each accompanies a
180///   FAILING `register_ack`: the registration did not happen.
181const FATAL_RELAY_ERROR_CODES: [u32; 5] = [1, 4, 5, 6, 7];
182
183/// Whether a relay `Error` frame means the RESERVATION is invalid (end the loop, back off and
184/// re-register) rather than a single request having failed (log it; keep serving).
185///
186/// The relay reports both kinds on one channel, and the per-request kind is the COMMON one:
187/// `3 PEER_NOT_FOUND` simply means the peer we tried to reach is no longer on this relay, which
188/// happens constantly on a live network and says nothing about our own registration. Treating it as
189/// fatal cost the node its reservation on every failed dial (dig_ecosystem #1932).
190///
191/// An UNKNOWN (future) code is deliberately treated as NON-fatal. Guessing wrong in that direction
192/// costs one logged line; guessing wrong the other way would let a newly-introduced code drop every
193/// node on the network off its relay at once.
194pub fn relay_error_is_fatal(code: u32) -> bool {
195    FATAL_RELAY_ERROR_CODES.contains(&code)
196}
197
198/// Compute the next reconnect backoff: capped exponential in the number of consecutive failures.
199/// `failures == 0` → base; doubles each failure up to [`MAX_BACKOFF_SECS`]. Pure → unit-tested.
200pub fn backoff_secs(consecutive_failures: u32) -> u64 {
201    backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
202}
203
204/// Capped-exponential backoff with an explicit base + cap. Always returns a value in `[base, cap]`
205/// — never zero — so a failing connect can never busy-loop.
206fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
207    let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
208    shifted.clamp(base, cap)
209}
210
211/// Backoff schedule for the reconnect loop — production defaults, or fast values for tests.
212#[derive(Debug, Clone, Copy)]
213pub struct Backoff {
214    /// First-retry delay (seconds).
215    pub base_secs: u64,
216    /// Upper bound on the delay (seconds).
217    pub cap_secs: u64,
218}
219
220impl Default for Backoff {
221    fn default() -> Self {
222        Backoff {
223            base_secs: BASE_BACKOFF_SECS,
224            cap_secs: MAX_BACKOFF_SECS,
225        }
226    }
227}
228
229/// The four observable states of the relay reservation, surfaced verbatim (lowercase) as the
230/// `state` field of a `control.relayStatus`-style RPC.
231///
232/// - `Disabled` — reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
233/// - `Connecting` — actively dialing/registering.
234/// - `Connected` — a reservation is held (`RegisterAck{success:true}` arrived); reachable to peers.
235/// - `Disconnected` — not connected; backing off + will retry. The graceful-fallback resting state.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum RelayState {
238    /// Reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
239    Disabled,
240    /// Actively dialing/registering (initial attempt or a reconnect in flight).
241    Connecting,
242    /// A reservation is held (`RegisterAck{success:true}` arrived); reachable to NAT'd peers.
243    Connected,
244    /// Not connected; backing off + will retry. The graceful-fallback resting state.
245    Disconnected,
246}
247
248impl RelayState {
249    /// The stable lowercase wire string for the RPC `state` field.
250    pub fn as_str(self) -> &'static str {
251        match self {
252            RelayState::Disabled => "disabled",
253            RelayState::Connecting => "connecting",
254            RelayState::Connected => "connected",
255            RelayState::Disconnected => "disconnected",
256        }
257    }
258
259    fn to_u8(self) -> u8 {
260        match self {
261            RelayState::Disabled => 0,
262            RelayState::Connecting => 1,
263            RelayState::Connected => 2,
264            RelayState::Disconnected => 3,
265        }
266    }
267
268    fn from_u8(v: u8) -> Self {
269        match v {
270            0 => RelayState::Disabled,
271            1 => RelayState::Connecting,
272            2 => RelayState::Connected,
273            _ => RelayState::Disconnected,
274        }
275    }
276}
277
278/// The peers discovered over the live reservation socket, in insertion order with O(1) dedup +
279/// membership by `peer_id`, bounded to [`MAX_KNOWN_PEERS`].
280///
281/// `order` preserves discovery order so [`RelayStatus::known_peers`] returns a stable sequence;
282/// `ids` mirrors `order`'s `peer_id`s so dedup and removal are O(1) instead of a linear scan (the
283/// old `iter().any(...)` was O(n²) over a flood). The two are kept in lockstep — every mutation
284/// touches both.
285#[derive(Debug, Default)]
286struct DiscoveredPeers {
287    order: Vec<RelayPeerInfo>,
288    ids: HashSet<String>,
289}
290
291impl DiscoveredPeers {
292    /// Insert `peer` unless already present or the set is full. Returns nothing — a full set simply
293    /// drops the newcomer (the untrusted-relay flood defense).
294    fn insert(&mut self, peer: RelayPeerInfo) {
295        if self.order.len() >= MAX_KNOWN_PEERS {
296            return;
297        }
298        if self.ids.insert(peer.peer_id.clone()) {
299            self.order.push(peer);
300        }
301    }
302
303    /// Remove the peer with this `peer_id`, if present.
304    fn remove(&mut self, peer_id: &str) {
305        if self.ids.remove(peer_id) {
306            self.order.retain(|p| p.peer_id != peer_id);
307        }
308    }
309
310    /// Replace the whole set from a `Peers` frame, deduped + truncated to the cap.
311    fn replace(&mut self, peers: Vec<RelayPeerInfo>) {
312        self.order.clear();
313        self.ids.clear();
314        for peer in peers {
315            self.insert(peer);
316        }
317    }
318
319    fn clear(&mut self) {
320        self.order.clear();
321        self.ids.clear();
322    }
323}
324
325/// Live relay-connection status, shared (via `Arc`) between the connection task and an RPC handler.
326/// Cheap atomic reads. State setters do STATE-CHANGE-ONLY logging so a long outage never hot-loops
327/// identical error lines.
328#[derive(Debug)]
329pub struct RelayStatus {
330    state: AtomicU8,
331    reconnect_attempts: AtomicU32,
332    connected_peers: AtomicU64,
333    last_error: Mutex<Option<String>>,
334    /// Peers learned over the LIVE reservation socket — the relay's `GetPeers` response (RLY-005)
335    /// plus `PeerConnected`/`PeerDisconnected` pushes. This is the discovery output of the persistent
336    /// reservation: a consumer (dig-gossip's pool/address book) reads it instead of reopening an
337    /// ephemeral socket per pass. Keyed by `peer_id` (deduped); bounded to [`MAX_KNOWN_PEERS`] so an
338    /// untrusted relay can't exhaust memory; cleared on every reconnect so a stale list is never
339    /// served across a drop.
340    known_peers: Mutex<DiscoveredPeers>,
341    /// Sink that injects an outbound [`RelayMessage`] into the LIVE reservation socket's write half.
342    /// `Some` only while a session is held (set by `connect_once`, cleared on every drop) — this is
343    /// what lets a [`RelayTunnel`] reuse the ONE persistent reservation socket for RLY-002 relayed
344    /// transport instead of opening a second connection.
345    outbound: Mutex<Option<mpsc::UnboundedSender<RelayMessage>>>,
346    /// This node's own `peer_id` (hex), stamped as `from` on every RLY-002 frame the tunnels send.
347    /// Set when a session registers; needed because a tunnel is opened from the shared status handle.
348    local_peer_id: Mutex<Option<String>>,
349    /// The network id this reservation registered under. Echoed onto an inbound accepted tunnel (the
350    /// RLY-002 frame itself does not carry it). Set alongside [`local_peer_id`] when a session registers.
351    local_network_id: Mutex<Option<String>>,
352    /// Sink that surfaces an INTRODUCED inbound circuit — a frame from a peer with NO open outbound
353    /// tunnel — as a server-role [`RelayTunnel`] for a consumer to accept + serve
354    /// ([`crate::accept::RelayAcceptor`]). `None` (default) = the original untrusted-relay behavior:
355    /// drop an unknown-peer frame. `Some` once a consumer calls [`RelayStatus::enable_accept`].
356    ///
357    /// This is the RESPONDER counterpart to [`open_tunnel`](Self::open_tunnel): a relay circuit needs
358    /// exactly ONE mTLS client + ONE mTLS server. The DIALER calls `open_tunnel` and runs
359    /// `PeerSession::client`; the reservation-HOLDER that RECEIVES the introduced circuit accepts here
360    /// and runs `PeerSession::server`. Without this path both ends acted as TLS client and the
361    /// handshake deadlocked (`got ClientHello when expecting ServerHello`, #1536).
362    inbound_accept: Mutex<Option<mpsc::Sender<RelayTunnel>>>,
363    /// Open relayed-transport tunnels, keyed by the REMOTE peer's `peer_id` (hex). An inbound RLY-002
364    /// `relay_message` from a peer is routed to its tunnel's inbound channel; a frame from a peer with
365    /// no open tunnel is dropped (the untrusted-relay default). Entries are removed on tunnel drop.
366    /// Each entry carries the mTLS [`TunnelRole`] we run over it so `route_relayed` can resolve the
367    /// #1536 simultaneous-mutual-dial glare deterministically.
368    tunnels: Mutex<HashMap<String, TunnelEntry>>,
369    /// Monotonic per-node sequence number stamped on outbound RLY-002 frames (ordering/dedup).
370    relay_seq: AtomicU64,
371    /// Monotonic id assigned to each tunnel registration so a stale [`RelayTunnel`]'s `Drop` never
372    /// deregisters a NEWER entry under the same peer key (see [`TunnelEntry::id`]; #1536 glare replace).
373    next_tunnel_id: AtomicU64,
374    /// Supplies the aggregated DHT-record view answered to RLY-009 `get_dht_records`
375    /// (dig_ecosystem #1935). `None` (the default) means this node answers NOTHING — a node that
376    /// has not opted in is indistinguishable on the wire from a pre-RLY-009 one, which is the safe
377    /// default for an observability feature.
378    ///
379    /// A closure rather than a data field because the records live in the DHT layer, which sits
380    /// ABOVE this crate in the hierarchy: `dig-dht` depends on `dig-nat`, never the reverse. The
381    /// consumer registers a reader; this crate only forwards the answer.
382    dht_records: Mutex<DhtRecordsHook>,
383}
384
385/// Holds the optional RLY-009 reader. A closure has no `Debug`, and `RelayStatus` derives it, so the
386/// hook is wrapped rather than making every field's diagnostics hand-written.
387#[derive(Default)]
388struct DhtRecordsHook(Option<Arc<DhtRecordsProvider>>);
389
390impl std::fmt::Debug for DhtRecordsHook {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        // Whether a reader is registered is the only useful diagnostic; the closure itself is opaque.
393        f.write_str(if self.0.is_some() {
394            "DhtRecordsHook(registered)"
395        } else {
396            "DhtRecordsHook(none)"
397        })
398    }
399}
400
401/// Reads this node's aggregated DHT provider records, bounded by the caller's `max_keys`, for the
402/// RLY-009 answer. Registered by the consumer via [`RelayStatus::set_dht_records_provider`].
403pub type DhtRecordsProvider = dyn Fn(usize) -> DhtRecordsAnswer + Send + Sync;
404
405/// What a node reports for RLY-009 — the wire payload, before it becomes a
406/// [`RelayMessage::DhtRecords`].
407#[derive(Debug, Clone, Default, PartialEq, Eq)]
408pub struct DhtRecordsAnswer {
409    /// Content keys and their live provider COUNTS. Never provider identities.
410    pub records: Vec<crate::wire::DhtRecordEntry>,
411    /// Keys with a live provider before `max_keys` was applied.
412    pub total_keys: usize,
413    /// Whether `max_keys` dropped entries.
414    pub truncated: bool,
415}
416
417impl Default for RelayStatus {
418    fn default() -> Self {
419        RelayStatus {
420            state: AtomicU8::new(RelayState::Disconnected.to_u8()),
421            reconnect_attempts: AtomicU32::new(0),
422            connected_peers: AtomicU64::new(0),
423            last_error: Mutex::new(None),
424            known_peers: Mutex::new(DiscoveredPeers::default()),
425            outbound: Mutex::new(None),
426            local_peer_id: Mutex::new(None),
427            local_network_id: Mutex::new(None),
428            inbound_accept: Mutex::new(None),
429            tunnels: Mutex::new(HashMap::new()),
430            relay_seq: AtomicU64::new(0),
431            next_tunnel_id: AtomicU64::new(0),
432            dht_records: Mutex::new(DhtRecordsHook::default()),
433        }
434    }
435}
436
437impl RelayStatus {
438    /// A fresh status (resting `Disconnected` until the task runs / the relay is reached).
439    pub fn new() -> Arc<Self> {
440        Arc::new(RelayStatus::default())
441    }
442
443    /// Read the current state.
444    pub fn state(&self) -> RelayState {
445        RelayState::from_u8(self.state.load(Ordering::Relaxed))
446    }
447
448    /// Transition to `next`, returning `true` IFF the state actually changed. Callers use the return
449    /// to log ONCE per transition (no hot-loop spam).
450    fn transition_to(&self, next: RelayState) -> bool {
451        let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
452        prev != next.to_u8()
453    }
454
455    /// Enter `Disabled` (reservation off). Idempotent; logs only on the first entry.
456    pub fn set_disabled(&self) {
457        if self.transition_to(RelayState::Disabled) {
458            tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
459        }
460    }
461
462    /// Enter `Connecting`. Logs only on the transition (so reconnect attempts don't spam).
463    pub fn set_connecting(&self) {
464        if self.transition_to(RelayState::Connecting) {
465            tracing::debug!("relay connecting");
466        }
467    }
468
469    /// Mark `Connected` (clears the last error, resets the attempt counter). Logs recovery once.
470    pub fn set_connected(&self, connected_peers: u64) {
471        self.connected_peers
472            .store(connected_peers, Ordering::Relaxed);
473        self.reconnect_attempts.store(0, Ordering::Relaxed);
474        *self.last_error.lock().unwrap() = None;
475        if self.transition_to(RelayState::Connected) {
476            tracing::info!(connected_peers, "relay reservation established");
477        }
478    }
479
480    /// Mark `Disconnected` with an optional error and bump the attempt counter. Logs the failure
481    /// ONLY on the transition into `Disconnected` (the first drop); subsequent failed retries while
482    /// already `Disconnected` update the error/counter SILENTLY.
483    pub fn set_disconnected(&self, error: Option<String>) {
484        self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
485        if let Some(e) = &error {
486            *self.last_error.lock().unwrap() = Some(e.clone());
487        }
488        let changed = self.transition_to(RelayState::Disconnected);
489        if changed {
490            match &error {
491                Some(e) => tracing::warn!(
492                    error = %e,
493                    "relay reservation lost — node still serving; retrying in background"
494                ),
495                None => tracing::info!("relay reservation closed — retrying in background"),
496            }
497        }
498    }
499
500    /// Whether a relay session is currently held.
501    pub fn is_connected(&self) -> bool {
502        self.state() == RelayState::Connected
503    }
504
505    /// The current reconnect-attempt count (for tests / RPC).
506    pub fn reconnect_attempts(&self) -> u32 {
507        self.reconnect_attempts.load(Ordering::Relaxed)
508    }
509
510    /// Snapshot of the peers discovered over the live reservation socket (RLY-005 `Peers` +
511    /// `PeerConnected` pushes, minus `PeerDisconnected`). The consumer folds these into its address
512    /// book / pool. Returns a clone so the caller holds no lock.
513    pub fn known_peers(&self) -> Vec<RelayPeerInfo> {
514        self.known_peers.lock().unwrap().order.clone()
515    }
516
517    /// Count of peers currently discovered over the live reservation socket.
518    pub fn known_peer_count(&self) -> usize {
519        self.known_peers.lock().unwrap().order.len()
520    }
521
522    /// Replace the discovered-peer set with a `GetPeers` response (RLY-005 `Peers`), deduped and
523    /// truncated to [`MAX_KNOWN_PEERS`] (an untrusted relay could send an oversized frame).
524    fn replace_known_peers(&self, peers: Vec<RelayPeerInfo>) {
525        self.known_peers.lock().unwrap().replace(peers);
526    }
527
528    /// Fold in a relay-pushed `PeerConnected` notice, deduped by `peer_id`; dropped once the set is
529    /// full ([`MAX_KNOWN_PEERS`]) so a flood can't exhaust memory.
530    fn add_known_peer(&self, peer: RelayPeerInfo) {
531        self.known_peers.lock().unwrap().insert(peer);
532    }
533
534    /// Drop a peer on a relay-pushed `PeerDisconnected` notice.
535    fn remove_known_peer(&self, peer_id: &str) {
536        self.known_peers.lock().unwrap().remove(peer_id);
537    }
538
539    /// Clear the discovered-peer set (on every reconnect — the list is per-session).
540    fn clear_known_peers(&self) {
541        self.known_peers.lock().unwrap().clear();
542    }
543
544    // -- RLY-002 relayed transport (the tier-6 TURN fallback) ------------------------------------
545    //
546    // A relayed tunnel reuses the ONE persistent reservation socket: outbound frames go through
547    // `outbound` (drained by the reservation loop's write half), inbound `relay_message` frames are
548    // routed by `from` peer_id to the matching tunnel. Available only while the reservation is held.
549
550    /// Install the live session's outbound sink + this node's `peer_id` + the registered `network_id`.
551    /// Called by `connect_once` once registered; cleared by [`clear_transport`](Self::clear_transport)
552    /// on every drop.
553    fn set_transport(
554        &self,
555        peer_id: &str,
556        network_id: &str,
557        outbound: mpsc::UnboundedSender<RelayMessage>,
558    ) {
559        *self.local_peer_id.lock().unwrap() = Some(peer_id.to_string());
560        *self.local_network_id.lock().unwrap() = Some(network_id.to_string());
561        *self.outbound.lock().unwrap() = Some(outbound);
562    }
563
564    /// Register the reader that answers RLY-009 `get_dht_records` (dig_ecosystem #1935).
565    ///
566    /// Until this is called the node answers nothing, which is on the wire indistinguishable from a
567    /// pre-RLY-009 node — the correct default for an observability feature that publishes what this
568    /// node knows about the network.
569    ///
570    /// The reader receives the relay's requested `max_keys` and MUST honour it: the provider store is
571    /// attacker-influenced (any peer can `add_provider`), so an unbounded answer would let a Sybil
572    /// dictate the frame size on a socket this node depends on for reachability.
573    pub fn set_dht_records_provider<F>(&self, reader: F)
574    where
575        F: Fn(usize) -> DhtRecordsAnswer + Send + Sync + 'static,
576    {
577        self.dht_records.lock().unwrap().0 = Some(Arc::new(reader));
578    }
579
580    /// The registered RLY-009 reader, if the consumer opted in.
581    fn dht_records_provider(&self) -> Option<Arc<DhtRecordsProvider>> {
582        self.dht_records.lock().unwrap().0.clone()
583    }
584
585    /// Enable the RESPONDER (accept) path and return the receiver of INTRODUCED inbound circuits.
586    ///
587    /// A relayed connection needs one mTLS client + one mTLS server. The dialer opens a tunnel and
588    /// runs the client; the reservation-HOLDER calls this once at startup and, for every inbound
589    /// frame from a peer it has no open outbound tunnel to, receives a server-role [`RelayTunnel`]
590    /// here to hand to a [`crate::accept::RelayAcceptor`] (which runs `PeerSession::server`). Until a
591    /// consumer calls this, unknown-peer frames are DROPPED (the untrusted-relay default), so the
592    /// accept path is strictly opt-in. The channel is bounded ([`INBOUND_ACCEPT_CAP`]).
593    pub fn enable_accept(&self) -> mpsc::Receiver<RelayTunnel> {
594        let (tx, rx) = mpsc::channel(INBOUND_ACCEPT_CAP);
595        *self.inbound_accept.lock().unwrap() = Some(tx);
596        rx
597    }
598
599    /// Tear down the transport on session drop: drop the outbound sink (so tunnel sends fail fast)
600    /// and close every open tunnel's inbound channel (so a blocked `recv` wakes with `None`).
601    fn clear_transport(&self) {
602        *self.outbound.lock().unwrap() = None;
603        self.tunnels.lock().unwrap().clear();
604    }
605
606    /// Whether a relayed tunnel can currently be opened — a reservation is held AND its outbound sink
607    /// is live. The tier-6 [`RelayedTransport`](crate::method::relayed::RelayedTransport) gates on this.
608    pub fn relay_transport_ready(&self) -> bool {
609        self.is_connected() && self.outbound.lock().unwrap().is_some()
610    }
611
612    /// Open an RLY-002 relayed-transport tunnel to `target_peer` (hex `peer_id`) over the held
613    /// reservation socket — the traversal ladder's FINAL tier when a pair can neither direct-dial nor
614    /// hole-punch. The returned [`RelayTunnel`] sends/receives opaque payloads that the relay forwards
615    /// A→relay→B; per NC-1 the payload is END-TO-END SEALED to the recipient so the relay forwards
616    /// ciphertext only. `Err` if no reservation is held. Dropping the tunnel deregisters it.
617    pub fn open_tunnel(
618        self: &Arc<Self>,
619        target_peer: &str,
620        network_id: &str,
621    ) -> Result<RelayTunnel, String> {
622        if !self.relay_transport_ready() {
623            return Err("relay reservation not connected — cannot open relayed tunnel".into());
624        }
625        // Self / SPKI-collision guard: a relayed circuit to our OWN peer_id has no lower/higher end
626        // for the glare tie-break, so it could never converge to one-client-one-server — refuse it
627        // (#1536).
628        let local = self
629            .local_peer_id
630            .lock()
631            .unwrap()
632            .clone()
633            .unwrap_or_default();
634        if !local.is_empty() && local == target_peer {
635            return Err("refusing relayed self-dial (target == local peer_id)".into());
636        }
637        // NON-CLOBBER (#1536): if a circuit to this peer already exists — because an introduced
638        // ClientHello made us its SERVER before this dial ran (a timing-ordered glare) — do NOT open a
639        // second, conflicting circuit under the same key. The existing circuit IS the connection; a
640        // duplicate would orphan one role and leave two mTLS sessions racing to one peer. Checked +
641        // inserted under ONE lock so a concurrent `route_relayed` cannot slip a role in between.
642        let mut tunnels = self.tunnels.lock().unwrap();
643        // #1871: only a LIVE circuit may suppress the dial. A registration that has carried no inbound
644        // frame for [`STALE_CIRCUIT_IDLE`] is not the glare this guard defends against — it is a
645        // phantom the peer pool cannot see, and honouring it suppresses the last-resort tier forever.
646        // Evict it and let the dial proceed: a redundant circuit costs one socket, a suppressed one
647        // costs all connectivity to that peer.
648        match tunnels.get(target_peer) {
649            Some(entry) if entry.last_activity.elapsed() < STALE_CIRCUIT_IDLE => {
650                return Err("existing relay circuit to peer — not opening a duplicate".into());
651            }
652            Some(_) => {
653                // Fall through to `insert_entry`, which overwrites the key. The displaced entry's
654                // `RelayTunnel` may still be held by a stuck task, but its `Drop` is id-matched
655                // (`close_tunnel`), so it cannot deregister the fresh circuit that replaced it.
656                tracing::debug!(
657                    target_peer,
658                    idle_secs = STALE_CIRCUIT_IDLE.as_secs(),
659                    "replacing a stale relay circuit — no inbound frame within the idle window"
660                );
661            }
662            None => {}
663        }
664        // A dialer runs the mTLS client over the tunnel it opens.
665        Ok(self.insert_entry(&mut tunnels, target_peer, network_id, TunnelRole::Client))
666    }
667
668    /// Register a tunnel routing entry for `target_peer` and build its [`RelayTunnel`], overwriting any
669    /// existing entry under the key. Test-only (the `open_server_tunnel` + flood-cap tests use it); the
670    /// production paths ([`open_tunnel`](Self::open_tunnel) + [`accept_introduced`](Self::accept_introduced))
671    /// go through non-clobber checks first.
672    #[cfg(test)]
673    fn register_tunnel(
674        self: &Arc<Self>,
675        target_peer: &str,
676        network_id: &str,
677        role: TunnelRole,
678    ) -> RelayTunnel {
679        let mut tunnels = self.tunnels.lock().unwrap();
680        self.insert_entry(&mut tunnels, target_peer, network_id, role)
681    }
682
683    /// Build a fresh [`RelayTunnel`] for `target_peer` with `role` and insert its entry into the
684    /// already-locked `tunnels` map (assigning a monotonic id so a stale `Drop` never evicts a newer
685    /// registration). The caller holds the lock, so the check-then-insert is atomic — the #1536
686    /// non-clobber + role-race defense.
687    fn insert_entry(
688        self: &Arc<Self>,
689        tunnels: &mut HashMap<String, TunnelEntry>,
690        target_peer: &str,
691        network_id: &str,
692        role: TunnelRole,
693    ) -> RelayTunnel {
694        let (tx, rx) = mpsc::channel(RELAY_TUNNEL_INBOUND_CAP);
695        let id = self.next_tunnel_id.fetch_add(1, Ordering::Relaxed);
696        tunnels.insert(
697            target_peer.to_string(),
698            TunnelEntry {
699                sink: tx,
700                role,
701                id,
702                last_activity: Instant::now(),
703            },
704        );
705        RelayTunnel {
706            target: target_peer.to_string(),
707            network_id: network_id.to_string(),
708            status: Arc::clone(self),
709            inbound: rx,
710            id,
711        }
712    }
713
714    /// Route one inbound RLY-002 `relay_message` to its tunnel by `from` peer_id. Oversized payloads
715    /// are dropped (size cap); a frame from a peer with no open tunnel becomes an introduced circuit
716    /// ONLY when it is a dialer's opening ClientHello (#1761 — see
717    /// [`accept_introduced`](Self::accept_introduced)), and is otherwise dropped, as it is when the
718    /// responder path is off or a flood cap is hit; a full inbound channel drops the frame
719    /// (backpressure). Returns silently in every drop case (untrusted relay).
720    ///
721    /// GLARE (#1536): when a ClientHello arrives on a tunnel where WE are ALSO the client — the peer
722    /// dialed us at the same time we dialed it — a deterministic tie-break makes exactly ONE side the
723    /// server: the numerically-LOWER `peer_id` becomes the server. Both ends compute the same rule, so
724    /// a crossed pair converges to one-client-one-server under ANY frame ordering with no retry loop.
725    /// The TIMING-ordered variant (a peer's ClientHello arrives BEFORE our own dial registers) cannot
726    /// produce a conflicting second circuit either: [`open_tunnel`](Self::open_tunnel) is non-clobber,
727    /// so once we serve a peer our later dial to it is refused. The per-frame role LOOKUP + any
728    /// same-frame yield (client-tunnel removal) happen under a single lock acquisition; the server
729    /// registration in [`accept_introduced`](Self::accept_introduced) re-acquires and re-checks
730    /// (non-clobber), so a dial racing between the two regions is abandoned, never a double-session.
731    fn route_relayed(self: &Arc<Self>, from: &str, payload: Vec<u8>) {
732        if payload.len() > MAX_RELAY_PAYLOAD {
733            tracing::debug!(
734                from,
735                len = payload.len(),
736                "dropping oversized relayed frame"
737            );
738            return;
739        }
740        let local = self
741            .local_peer_id
742            .lock()
743            .unwrap()
744            .clone()
745            .unwrap_or_default();
746        // Self / SPKI-collision guard: a frame stamped with our OWN id can never be a real remote peer
747        // (and the tie-break has no lower/higher end for it) — drop it rather than risk a no-server
748        // hang (#1536).
749        if !local.is_empty() && local == from {
750            tracing::debug!("dropping relayed frame stamped with our own peer_id (self/collision)");
751            return;
752        }
753
754        // Decide the action for this frame under ONE tunnels-lock so a concurrent `open_tunnel` cannot
755        // race the role assignment.
756        enum Route {
757            /// Deliver into an existing tunnel's inbound sink.
758            Deliver(mpsc::Sender<Vec<u8>>),
759            /// Drop the frame (we retain our client role, or cannot serve).
760            Ignore,
761            /// Accept as an introduced server-role circuit.
762            Accept,
763        }
764        let route = {
765            let mut tunnels = self.tunnels.lock().unwrap();
766            // An inbound frame is this circuit's proof of life, so stamp it before routing: staleness
767            // (#1871) is measured from LAST ACTIVITY, never from registration, or a healthy long-lived
768            // relayed session would age into "replaceable" and be clobbered by the next dial.
769            //
770            // This counts ARRIVAL, not validity — it is deliberately ahead of the `match`, so a frame
771            // that is then ignored (glare tie-break) or dropped (full/closed sink) still refreshes the
772            // circuit. That is right for a genuine peer, whose frames prove it is there whatever we do
773            // with them; it also means an INJECTING relay can keep a phantom alive. The window is a
774            // liveness heuristic, never a security boundary — see [`STALE_CIRCUIT_IDLE`].
775            if let Some(entry) = tunnels.get_mut(from) {
776                entry.last_activity = Instant::now();
777            }
778            match tunnels.get(from).map(|e| (e.sink.clone(), e.role, e.id)) {
779                // We are the SERVER for this peer — every frame is the client's; route it.
780                Some((sink, TunnelRole::Server, _)) => Route::Deliver(sink),
781                // We are the CLIENT. A ServerHello / app record is the expected response; a ClientHello
782                // means the peer dialed us at the same time (GLARE).
783                Some((sink, TunnelRole::Client, id)) => {
784                    if !is_tls_client_hello(&payload) {
785                        Route::Deliver(sink)
786                    } else if local.as_str() > from {
787                        // Higher id → WE keep the client role; ignore the peer's competing ClientHello
788                        // (the lower-id peer yields to server and answers with a ServerHello).
789                        tracing::debug!(
790                            from,
791                            "relay glare — retaining client role (peer yields to server)"
792                        );
793                        Route::Ignore
794                    } else if self.inbound_accept.lock().unwrap().is_none() {
795                        // Lower id → should be server, but no responder path is enabled; cannot serve,
796                        // so keep the (doomed) client tunnel and drop rather than tear it down.
797                        tracing::debug!(
798                            from,
799                            "relay glare — should be server but accept path off; dropping"
800                        );
801                        Route::Ignore
802                    } else {
803                        // Lower id → yield to the server role: drop our client tunnel (only if it is
804                        // still ours) and accept the peer's circuit as server below.
805                        if tunnels.get(from).map(|e| e.id) == Some(id) {
806                            tunnels.remove(from);
807                        }
808                        tracing::debug!(from, "relay glare — yielding to server role");
809                        Route::Accept
810                    }
811                }
812                // No circuit under this key — a candidate INTRODUCED circuit (a peer dialing us over
813                // the relay). Whether it really is one is decided by the frame's direction in
814                // `accept_introduced`, which admits only a dialer's opening ClientHello (#1761).
815                None => Route::Accept,
816            }
817        };
818
819        match route {
820            Route::Deliver(sink) => {
821                if sink.try_send(payload).is_err() {
822                    tracing::debug!(from, "relayed tunnel inbound full/closed — frame dropped");
823                }
824            }
825            Route::Ignore => {}
826            Route::Accept => self.accept_introduced(from, payload),
827        }
828    }
829
830    /// Accept an INTRODUCED inbound circuit from `from` as a server-role tunnel and surface it to the
831    /// consumer's [`RelayAcceptor`](crate::accept::RelayAcceptor) — the RESPONDER path. Gated on: the
832    /// frame actually being a dialer's OPENING HANDSHAKE (below), the responder path being enabled
833    /// ([`enable_accept`](Self::enable_accept)), NON-CLOBBER (a circuit already under this key is kept,
834    /// never replaced by a racing registration — the #1536 double-session defense), and the flood cap
835    /// ([`MAX_RELAY_TUNNELS`]). The opening frame (the dialer's ClientHello) is delivered into the fresh
836    /// tunnel so the server handshake sees it; a full accept channel drops the newest circuit (bounded
837    /// backpressure).
838    ///
839    /// This is the ONE place a server-role circuit is created, so the mTLS role of a relayed circuit is
840    /// decided HERE and only here, from the circuit's own direction — never from what the accept path
841    /// happens to be handed.
842    fn accept_introduced(self: &Arc<Self>, from: &str, payload: Vec<u8>) {
843        // #1761: ONLY a client's opening handshake may open an inbound circuit. A relayed frame from a
844        // peer we hold no tunnel to is otherwise NOT the start of a circuit — it is a frame belonging to
845        // a circuit that no longer exists here, or one that was never ours. The whole class must be
846        // dropped, not just the observed instance: a peer's ServerHello or application record arriving
847        // after `fast_connect` released the per-peer tunnel on a relayed→direct promotion, a frame that
848        // outlived a timed-out or torn-down circuit, and any garbage an untrusted relay injects. Accepting
849        // any of them stands up a TLS SERVER against a peer that is itself a server, which is the live
850        // `got ServerHello when expecting ClientHello` / `UnexpectedMessage` deadlock — and it also let a
851        // single arbitrary byte cost a tunnel slot plus an accept-task.
852        if !is_tls_client_hello(&payload) {
853            // The relay-supplied `from` is deliberately NOT logged: it is an untrusted, unbounded
854            // peer-controlled string, and this line is reachable by any frame a hostile relay cares to
855            // send. The frame length is the only diagnostic that cannot carry attacker text.
856            tracing::debug!(
857                len = payload.len(),
858                "dropping a relayed frame that is not a dialer's opening handshake — no circuit is open \
859                 for this peer"
860            );
861            return;
862        }
863        let Some(accept_tx) = self.inbound_accept.lock().unwrap().clone() else {
864            return;
865        };
866        let network_id = self
867            .local_network_id
868            .lock()
869            .unwrap()
870            .clone()
871            .unwrap_or_default();
872        let tunnel = {
873            let mut tunnels = self.tunnels.lock().unwrap();
874            if tunnels.contains_key(from) {
875                // A circuit to this peer already exists (a racing dial claimed the key) — do NOT
876                // clobber it into a conflicting second session.
877                return;
878            }
879            if tunnels.len() >= MAX_RELAY_TUNNELS {
880                tracing::debug!(
881                    from,
882                    "inbound relay accept cap reached — dropping introduced circuit"
883                );
884                return;
885            }
886            self.insert_entry(&mut tunnels, from, &network_id, TunnelRole::Server)
887        };
888        // Deliver the opening frame so the server-side handshake sees the ClientHello.
889        if let Some(sink) = self
890            .tunnels
891            .lock()
892            .unwrap()
893            .get(from)
894            .map(|e| e.sink.clone())
895        {
896            let _ = sink.try_send(payload);
897        }
898        // Hand the server-role tunnel to the consumer to run `PeerSession::server` over. A full/closed
899        // accept channel drops the tunnel here — its `Drop` deregisters the routing.
900        if accept_tx.try_send(tunnel).is_err() {
901            tracing::debug!(
902                from,
903                "inbound accept channel full/closed — dropping introduced circuit"
904            );
905        }
906    }
907
908    /// Remove a tunnel's routing entry (called on [`RelayTunnel`] drop) — but ONLY when the stored
909    /// entry is still THIS registration (`id` matches). A glare tie-break can replace our client
910    /// tunnel with a server tunnel under the same peer key (#1536); the old client tunnel's `Drop`
911    /// must not then evict the newer server entry.
912    fn close_tunnel(&self, target_peer: &str, id: u64) {
913        let mut tunnels = self.tunnels.lock().unwrap();
914        if tunnels.get(target_peer).map(|e| e.id) == Some(id) {
915            tunnels.remove(target_peer);
916        }
917    }
918
919    /// Whether a relayed tunnel to `target_peer` is currently registered — the test hook fast-connect
920    /// uses to assert the per-peer tunnel was released (dropped) after a relayed→direct promotion,
921    /// while the reservation itself stays held.
922    #[cfg(test)]
923    pub(crate) fn open_tunnel_exists(&self, target_peer: &str) -> bool {
924        self.tunnels.lock().unwrap().contains_key(target_peer)
925    }
926
927    /// Test-only: rewind a registered tunnel's `last_activity` by `age`, so a test can express an
928    /// idle circuit without sleeping through [`STALE_CIRCUIT_IDLE`] in wall-clock time.
929    #[cfg(test)]
930    pub(crate) fn backdate_tunnel(&self, target_peer: &str, age: Duration) {
931        let mut tunnels = self.tunnels.lock().unwrap();
932        let entry = tunnels
933            .get_mut(target_peer)
934            .expect("backdate_tunnel: no such tunnel");
935        entry.last_activity = Instant::now()
936            .checked_sub(age)
937            .expect("backdate_tunnel: age precedes the monotonic clock origin");
938    }
939
940    /// Test-only: the mTLS role registered under `target_peer`, if any — lets a test assert WHICH
941    /// registration holds the key after a stale circuit is replaced.
942    #[cfg(test)]
943    fn tunnel_role(&self, target_peer: &str) -> Option<TunnelRole> {
944        self.tunnels
945            .lock()
946            .unwrap()
947            .get(target_peer)
948            .map(|e| e.role)
949    }
950
951    /// Test-only: register a SERVER-role tunnel to `target_peer` directly, for tests that drive an mTLS
952    /// SERVER over a hand-wired relay tunnel (the production server path is [`enable_accept`] +
953    /// [`route_relayed`]'s accept branch). A server-role tunnel routes an incoming ClientHello straight
954    /// through instead of treating it as the #1536 glare signal, so these tests mirror a real server
955    /// receiving a dialer's ClientHello.
956    #[cfg(test)]
957    pub(crate) fn open_server_tunnel(
958        self: &Arc<Self>,
959        target_peer: &str,
960        network_id: &str,
961    ) -> RelayTunnel {
962        self.register_tunnel(target_peer, network_id, TunnelRole::Server)
963    }
964
965    /// A JSON snapshot for a `control.relayStatus`-style RPC. `state` is the canonical truth;
966    /// `connected` is a convenience boolean (== `state == connected`).
967    pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
968        let state = self.state();
969        serde_json::json!({
970            "state": state.as_str(),
971            "connected": state == RelayState::Connected,
972            "endpoint": endpoint,
973            "peer_id": peer_id,
974            "reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
975            "connected_peers": self.connected_peers.load(Ordering::Relaxed),
976            "last_error": *self.last_error.lock().unwrap(),
977        })
978    }
979}
980
981/// A live RLY-002 relayed-transport tunnel to one peer, multiplexed over the node's persistent relay
982/// reservation socket (the tier-6 TURN fallback). Writes are framed as RLY-002 `relay_message` to the
983/// target and forwarded A→relay→B; reads are the payloads the relay forwards back from that peer.
984///
985/// Per NC-1 the payload MUST be END-TO-END SEALED to the recipient's key by the caller — the relay is
986/// an untrusted forwarder that sees only ciphertext. Dropping the tunnel deregisters its routing.
987pub struct RelayTunnel {
988    /// The remote peer's `peer_id` (hex) — the RLY-002 `to`, and the routing key for inbound frames.
989    target: String,
990    /// The network the tunnel is scoped to (echoed for the consumer; relay routes by peer_id).
991    network_id: String,
992    /// Shared status handle — provides the live outbound sink, this node's `peer_id`, and the seq.
993    status: Arc<RelayStatus>,
994    /// Inbound payloads the relay forwarded from `target`, in arrival order (bounded — see
995    /// [`RELAY_TUNNEL_INBOUND_CAP`]).
996    inbound: mpsc::Receiver<Vec<u8>>,
997    /// This registration's monotonic id — so `Drop` only deregisters when the map still holds THIS
998    /// entry (a glare replace may have superseded it under the same key; #1536).
999    id: u64,
1000}
1001
1002impl RelayTunnel {
1003    /// The remote peer this tunnel forwards to/from (hex `peer_id`).
1004    pub fn target(&self) -> &str {
1005        &self.target
1006    }
1007
1008    /// The network the tunnel is scoped to.
1009    pub fn network_id(&self) -> &str {
1010        &self.network_id
1011    }
1012
1013    /// Send `payload` to the target peer through the relay (RLY-002 `relay_message`). `payload` MUST
1014    /// already be sealed to the recipient (NC-1). `Err` if the reservation dropped (send after the
1015    /// session closed) or `payload` exceeds [`MAX_RELAY_PAYLOAD`].
1016    pub fn send(&self, payload: Vec<u8>) -> Result<(), String> {
1017        if payload.len() > MAX_RELAY_PAYLOAD {
1018            return Err(format!(
1019                "relayed payload {} exceeds cap {MAX_RELAY_PAYLOAD}",
1020                payload.len()
1021            ));
1022        }
1023        let from = self
1024            .status
1025            .local_peer_id
1026            .lock()
1027            .unwrap()
1028            .clone()
1029            .ok_or("relay reservation not connected — no local peer_id")?;
1030        let seq = self.status.relay_seq.fetch_add(1, Ordering::Relaxed);
1031        let frame = RelayMessage::RelayGossipMessage {
1032            from,
1033            to: self.target.clone(),
1034            payload,
1035            seq,
1036        };
1037        let guard = self.status.outbound.lock().unwrap();
1038        let sink = guard
1039            .as_ref()
1040            .ok_or("relay reservation not connected — cannot send relayed frame")?;
1041        sink.send(frame)
1042            .map_err(|_| "relay reservation write half closed".to_string())
1043    }
1044
1045    /// Await the next payload the relay forwards from the target peer. `None` once the reservation
1046    /// drops (the session closed) — the caller should re-open the tunnel after the relay reconnects.
1047    pub async fn recv(&mut self) -> Option<Vec<u8>> {
1048        self.inbound.recv().await
1049    }
1050
1051    /// Poll for the next inbound payload. This is the non-`async` primitive the
1052    /// [`RelayTunnelStream`](crate::tunnel::RelayTunnelStream) `AsyncRead` adapter drives so an mTLS
1053    /// session can run OVER the relay tunnel. `Poll::Ready(None)` once the reservation drops.
1054    pub(crate) fn poll_recv(
1055        &mut self,
1056        cx: &mut std::task::Context<'_>,
1057    ) -> std::task::Poll<Option<Vec<u8>>> {
1058        self.inbound.poll_recv(cx)
1059    }
1060}
1061
1062impl Drop for RelayTunnel {
1063    fn drop(&mut self) {
1064        self.status.close_tunnel(&self.target, self.id);
1065    }
1066}
1067
1068/// Resolve the relay endpoint: `DIG_RELAY_URL` if set + non-empty (and not the opt-out token), else
1069/// the canonical [`dig_constants::DIG_RELAY_URL`].
1070pub fn relay_url_from_env() -> String {
1071    std::env::var("DIG_RELAY_URL")
1072        .ok()
1073        .filter(|s| !s.trim().is_empty())
1074        .filter(|s| !is_off_token(s))
1075        .unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
1076}
1077
1078/// Whether the relay connection is enabled. Disabled when `DIG_RELAY_URL` is `off`/`disabled`/
1079/// empty-after-trim — an explicit opt-out for air-gapped/standalone nodes.
1080pub fn relay_enabled() -> bool {
1081    match std::env::var("DIG_RELAY_URL") {
1082        Ok(v) => !is_off_token(&v),
1083        Err(_) => true,
1084    }
1085}
1086
1087/// `true` if `v` is the reservation opt-out token (`off`/`disabled`, case-insensitive, trimmed).
1088fn is_off_token(v: &str) -> bool {
1089    let v = v.trim();
1090    v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
1091}
1092
1093/// Current unix time (seconds), saturating.
1094fn now_secs() -> u64 {
1095    std::time::SystemTime::now()
1096        .duration_since(std::time::UNIX_EPOCH)
1097        .map(|d| d.as_secs())
1098        .unwrap_or(0)
1099}
1100
1101/// Maintain a CONSTANT relay reservation forever: connect, register, keepalive, and on any drop
1102/// reconnect with capped exponential backoff. Spawned as a background task; tolerates the relay
1103/// being down (retries forever, never crashes). `peer_id` is the node's stable identity hex.
1104pub async fn run_relay_connection(
1105    endpoint: String,
1106    peer_id: String,
1107    network_id: String,
1108    listen_addrs: Vec<SocketAddr>,
1109    status: Arc<RelayStatus>,
1110) {
1111    run_relay_connection_with(
1112        endpoint,
1113        peer_id,
1114        network_id,
1115        listen_addrs,
1116        status,
1117        Backoff::default(),
1118    )
1119    .await
1120}
1121
1122/// [`run_relay_connection`] with an explicit backoff schedule (tests pass tiny values for fast,
1123/// deterministic reconnect timing; the LOGIC is identical — only the sleep durations differ).
1124pub async fn run_relay_connection_with(
1125    endpoint: String,
1126    peer_id: String,
1127    network_id: String,
1128    listen_addrs: Vec<SocketAddr>,
1129    status: Arc<RelayStatus>,
1130    backoff: Backoff,
1131) {
1132    let mut consecutive_failures: u32 = 0;
1133    loop {
1134        status.set_connecting();
1135        match connect_once(&endpoint, &peer_id, &network_id, &listen_addrs, &status).await {
1136            Ok(()) => {
1137                consecutive_failures = 0;
1138                status.set_disconnected(None);
1139            }
1140            Err(e) => {
1141                consecutive_failures = consecutive_failures.saturating_add(1);
1142                status.set_disconnected(Some(e));
1143            }
1144        }
1145        // ALWAYS sleep a bounded backoff before retrying — prevents a busy error loop.
1146        let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
1147        tokio::time::sleep(Duration::from_secs(delay)).await;
1148    }
1149}
1150
1151/// A relay WebSocket endpoint parsed into the pieces the happy-eyeballs dial needs: the host to
1152/// resolve and the TCP port. The scheme (`ws`/`wss`) only selects the default port here — the
1153/// plaintext-vs-TLS choice is re-derived from the URL by [`client_async_tls_with_config`] during the
1154/// handshake, so a single code path serves both.
1155#[derive(Debug, PartialEq, Eq)]
1156struct RelayEndpoint {
1157    host: String,
1158    port: u16,
1159}
1160
1161/// Parse a relay endpoint URL (`ws://host[:port][/path]` / `wss://host[:port][/path]`, IPv6 hosts in
1162/// `[…]`) into its host + port. Only the authority is needed for the dial; any path/query/fragment and
1163/// userinfo are ignored (the full URL is still handed to the WS handshake for the correct `Host`/SNI).
1164fn parse_relay_endpoint(endpoint: &str) -> Result<RelayEndpoint, String> {
1165    let (scheme, rest) = endpoint
1166        .split_once("://")
1167        .ok_or_else(|| format!("relay endpoint missing scheme: {endpoint}"))?;
1168    let default_port = match scheme.to_ascii_lowercase().as_str() {
1169        "ws" => 80,
1170        "wss" => 443,
1171        other => return Err(format!("unsupported relay scheme: {other}")),
1172    };
1173    // Authority only: drop any path/query/fragment, then any `userinfo@`.
1174    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
1175    let authority = authority
1176        .rsplit_once('@')
1177        .map(|(_, h)| h)
1178        .unwrap_or(authority);
1179
1180    let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
1181        // Bracketed IPv6 literal: `[addr]` or `[addr]:port`.
1182        let (h, after) = stripped
1183            .split_once(']')
1184            .ok_or_else(|| format!("malformed IPv6 authority: {authority}"))?;
1185        let port = match after.strip_prefix(':') {
1186            Some(p) => p.parse().map_err(|_| format!("bad relay port: {after}"))?,
1187            None => default_port,
1188        };
1189        (h.to_string(), port)
1190    } else if let Some((h, p)) = authority.rsplit_once(':') {
1191        (
1192            h.to_string(),
1193            p.parse().map_err(|_| format!("bad relay port: {p}"))?,
1194        )
1195    } else {
1196        (authority.to_string(), default_port)
1197    };
1198
1199    if host.is_empty() {
1200        return Err(format!("relay endpoint missing host: {endpoint}"));
1201    }
1202    Ok(RelayEndpoint { host, port })
1203}
1204
1205/// Resolve a relay host to its family-tagged dial candidates: a literal IP yields one candidate (no
1206/// DNS), a hostname is resolved to its full A + AAAA set. The candidates feed `dig_ip::connect`, which
1207/// applies the §5.2 IPv6-first preference + local∩peer family intersection, so no ordering is imposed
1208/// here — the addresses are added as resolved and tagged by family for observability.
1209async fn resolve_relay_candidates(host: &str, port: u16) -> Result<PeerCandidates, String> {
1210    let mut candidates = PeerCandidates::new();
1211    let source_for = |ip: &IpAddr| {
1212        if ip.is_ipv6() {
1213            CandidateSource::DnsAAAA
1214        } else {
1215            CandidateSource::DnsA
1216        }
1217    };
1218    if let Ok(ip) = host.parse::<IpAddr>() {
1219        candidates.add(SocketAddr::new(ip, port), source_for(&ip));
1220    } else {
1221        let resolved = tokio::net::lookup_host((host, port))
1222            .await
1223            .map_err(|e| format!("resolve {host}:{port}: {e}"))?;
1224        for addr in resolved {
1225            candidates.add(addr, source_for(&addr.ip()));
1226        }
1227    }
1228    if candidates.is_empty() {
1229        return Err(format!("no addresses resolved for {host}:{port}"));
1230    }
1231    Ok(candidates)
1232}
1233
1234/// Race the relay `candidates` IPv6-first with graceful IPv4 fallback via `dig_ip::connect` (§5.2,
1235/// RFC 8305). The transport connect stays a caller-supplied closure so the racing logic is unit-tested
1236/// with a fake dial (no real DNS/sockets) exactly as the direct-peer dialer does in `dialer.rs`; the
1237/// production caller ([`open_relay_ws`]) hands it a real [`TcpStream::connect`].
1238async fn race_relay_candidates<C, F, Fut>(
1239    local: &LocalStack,
1240    candidates: &PeerCandidates,
1241    config: DialConfig,
1242    dial_fn: F,
1243) -> Result<dig_ip::DialWinner<C>, String>
1244where
1245    F: Fn(SocketAddr) -> Fut + Sync,
1246    Fut: std::future::Future<Output = Result<C, String>> + Send,
1247    C: Send,
1248{
1249    dig_ip::connect(local, candidates, config, dial_fn)
1250        .await
1251        .map_err(|e| format!("relay happy-eyeballs dial: {e}"))
1252}
1253
1254/// Open the relay WebSocket over an IPv6-first happy-eyeballs TCP race (§5.2), matching the direct-peer
1255/// dial path in `dialer.rs`: resolve the endpoint host to its A + AAAA candidates, race the TCP connect
1256/// via `dig_ip::connect` (IPv6-first, fast IPv4 fallback), then run the WS handshake over the WINNING
1257/// socket — TLS-over-that-stream for `wss://`, plaintext for `ws://` (the mode is taken from the URL by
1258/// [`client_async_tls_with_config`]). Replaces `tokio_tungstenite::connect_async`, whose sequential,
1259/// single-family resolve-and-connect contradicted the IPv6-first reservation guarantee.
1260async fn open_relay_ws(
1261    endpoint: &str,
1262) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, String> {
1263    let parsed = parse_relay_endpoint(endpoint)?;
1264    let candidates = resolve_relay_candidates(&parsed.host, parsed.port).await?;
1265    let local = LocalStack::cached();
1266    let winner = race_relay_candidates(
1267        &local,
1268        &candidates,
1269        DialConfig::default(),
1270        |addr| async move {
1271            TcpStream::connect(addr)
1272                .await
1273                .map_err(|e| format!("tcp connect {addr}: {e}"))
1274        },
1275    )
1276    .await?;
1277    let (ws, _resp) = client_async_tls_with_config(endpoint, winner.conn, None, None)
1278        .await
1279        .map_err(|e| format!("ws handshake: {e}"))?;
1280    Ok(ws)
1281}
1282
1283/// One connect → register → serve cycle. Returns `Ok` on a clean close, `Err(reason)` on failure.
1284async fn connect_once(
1285    endpoint: &str,
1286    peer_id: &str,
1287    network_id: &str,
1288    listen_addrs: &[SocketAddr],
1289    status: &Arc<RelayStatus>,
1290) -> Result<(), String> {
1291    // Each session's discovered-peer set + transport are independent — never carry state across a
1292    // drop. `clear_transport` also runs at the end so a dropped session's tunnels/sink never linger.
1293    status.clear_known_peers();
1294    status.clear_transport();
1295
1296    let ws = open_relay_ws(endpoint).await?;
1297    let (mut write, mut read) = ws.split();
1298
1299    // RLY-001: register immediately so the relay holds our reservation, advertising the node's gossip
1300    // listen candidates (B1) so the relay can hand other peers a dialable candidate (§5.2 IPv6-first).
1301    let register = RelayMessage::Register {
1302        peer_id: peer_id.to_string(),
1303        network_id: network_id.to_string(),
1304        protocol_version: RELAY_PROTOCOL_VERSION,
1305        listen_addrs: listen_addrs.to_vec(),
1306    };
1307    send(&mut write, &register).await?;
1308
1309    // Publish the outbound sink so RLY-002 relayed tunnels can reuse THIS persistent socket. Drained
1310    // in the select loop below; cleared when the session ends.
1311    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayMessage>();
1312    status.set_transport(peer_id, network_id, out_tx);
1313
1314    // RLY-005: pull the current peer list right away, then again periodically — all over THIS
1315    // persistent socket, so discovery never requires reopening a connection.
1316    let get_peers = RelayMessage::GetPeers {
1317        network_id: Some(network_id.to_string()),
1318    };
1319    send(&mut write, &get_peers).await?;
1320
1321    let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
1322    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1323    ping.tick().await; // skip the immediate first tick
1324
1325    let mut discovery = tokio::time::interval(Duration::from_secs(DISCOVERY_INTERVAL_SECS));
1326    discovery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1327    discovery.tick().await; // skip the immediate first tick (we already pulled once above)
1328
1329    // Run the session; whatever the outcome, tear the transport down so a dropped session never
1330    // leaves a stale outbound sink or open tunnels behind (they'd send into a closed socket).
1331    let result = serve_session(
1332        &mut write,
1333        &mut read,
1334        &mut ping,
1335        &mut discovery,
1336        &mut out_rx,
1337        network_id,
1338        status,
1339    )
1340    .await;
1341    status.clear_transport();
1342    result
1343}
1344
1345/// The connected-session select loop: keepalive pings, periodic RLY-005 discovery, draining the
1346/// outbound relayed-transport sink onto the socket, and handling inbound frames. Returns `Ok` on a
1347/// clean close, `Err(reason)` on a failure. Split out of `connect_once` so its caller can always run
1348/// transport teardown regardless of how the session ends.
1349#[allow(clippy::too_many_arguments)]
1350async fn serve_session<W, R>(
1351    write: &mut W,
1352    read: &mut R,
1353    ping: &mut tokio::time::Interval,
1354    discovery: &mut tokio::time::Interval,
1355    out_rx: &mut mpsc::UnboundedReceiver<RelayMessage>,
1356    network_id: &str,
1357    status: &Arc<RelayStatus>,
1358) -> Result<(), String>
1359where
1360    W: SinkExt<Message> + Unpin,
1361    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1362    R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
1363{
1364    loop {
1365        tokio::select! {
1366            _ = ping.tick() => {
1367                send(write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
1368            }
1369            _ = discovery.tick() => {
1370                send(write, &RelayMessage::GetPeers {
1371                    network_id: Some(network_id.to_string()),
1372                }).await?;
1373            }
1374            // A relayed tunnel queued an RLY-002 frame — forward it over THIS persistent socket.
1375            Some(frame) = out_rx.recv() => {
1376                send(write, &frame).await?;
1377            }
1378            frame = read.next() => {
1379                match frame {
1380                    None => return Ok(()),
1381                    Some(Err(e)) => return Err(format!("read: {e}")),
1382                    Some(Ok(Message::Close(_))) => return Ok(()),
1383                    Some(Ok(Message::Ping(p))) => {
1384                        write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
1385                    }
1386                    Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
1387                    Some(Ok(Message::Text(t))) => {
1388                        handle_incoming(t.into_bytes(), write, status).await?;
1389                    }
1390                    Some(Ok(Message::Binary(b))) => {
1391                        handle_incoming(b, write, status).await?;
1392                    }
1393                }
1394            }
1395        }
1396    }
1397}
1398
1399/// Handle one decoded inbound relay frame: track RegisterAck (→ connected), answer relay Pings.
1400async fn handle_incoming<W>(
1401    bytes: Vec<u8>,
1402    write: &mut W,
1403    status: &Arc<RelayStatus>,
1404) -> Result<(), String>
1405where
1406    W: SinkExt<Message> + Unpin,
1407    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1408{
1409    let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
1410        return Ok(()); // ignore anything we can't parse; the relay is untrusted
1411    };
1412    match msg {
1413        RelayMessage::RegisterAck {
1414            success,
1415            message,
1416            connected_peers,
1417        } => {
1418            if success {
1419                status.set_connected(connected_peers as u64);
1420            } else {
1421                return Err(format!("register rejected: {message}"));
1422            }
1423        }
1424        RelayMessage::Ping { timestamp } => {
1425            send(write, &RelayMessage::Pong { timestamp }).await?;
1426        }
1427        // RLY-005 + push notices: fold peers discovered over the live socket into the status so the
1428        // consumer's pool/address book sees them without opening an ephemeral discovery connection.
1429        RelayMessage::Peers { peers } => status.replace_known_peers(peers),
1430        RelayMessage::PeerConnected { peer } => status.add_known_peer(peer),
1431        RelayMessage::PeerDisconnected { peer_id } => status.remove_known_peer(&peer_id),
1432        // RLY-002 relayed transport (tier-6 TURN): route a payload the relay forwarded from `from` to
1433        // that peer's open tunnel. Unknown-peer / oversized / full-channel frames are dropped inside
1434        // `route_relayed` (untrusted-relay defense). `to`/`seq` are the relay's concern; we key on
1435        // `from`. Per NC-1 `payload` is sealed ciphertext the relay could not read.
1436        RelayMessage::RelayGossipMessage { from, payload, .. } => {
1437            status.route_relayed(&from, payload)
1438        }
1439        // RLY-009 (#1935): the relay is asking what this node holds in its DHT provider store.
1440        // Answered only if the consumer registered a reader; otherwise we stay silent, which a
1441        // pre-RLY-009 relay and a non-participating node are equally free to do.
1442        RelayMessage::GetDhtRecords { max_keys } => {
1443            if let Some(reader) = status.dht_records_provider() {
1444                let answer = reader(max_keys);
1445                send(
1446                    write,
1447                    &RelayMessage::DhtRecords {
1448                        records: answer.records,
1449                        total_keys: answer.total_keys,
1450                        truncated: answer.truncated,
1451                    },
1452                )
1453                .await?;
1454            }
1455        }
1456        // A node never RECEIVES an answer — it is the one answering. Ignore rather than error, so a
1457        // confused or hostile relay cannot drop this node's reservation by echoing one back.
1458        RelayMessage::DhtRecords { .. } => {
1459            tracing::debug!("ignoring dht_records sent to a client");
1460        }
1461        RelayMessage::Error { code, message } => {
1462            // A relay `Error` frame is NOT automatically a reservation problem — the relay reports
1463            // per-REQUEST failures on the same channel. Treating them all as fatal made a routine
1464            // "the peer you asked for has left" (`PEER_NOT_FOUND`) tear down this node's own
1465            // reservation, de-register it, and force a full reconnect — a flap that repeatedly
1466            // pulled the node out of the relay's introductions (dig_ecosystem #1932).
1467            if relay_error_is_fatal(code) {
1468                return Err(format!("relay error {code}: {message}"));
1469            }
1470            tracing::debug!(code, %message, "relay reported a per-request error; reservation held");
1471        }
1472        other => tracing::debug!(?other, "relay message ignored by reservation loop"),
1473    }
1474    Ok(())
1475}
1476
1477/// Wire two in-memory relay reservations to forward RLY-002 frames to each OTHER — a loopback relay
1478/// with no real network. Each returned [`RelayStatus`] is `Connected` with a live outbound sink whose
1479/// frames are routed into the peer's tunnels by `from` peer_id, exactly as a real relay would forward
1480/// A→relay→B. Used to prove a full mTLS session round-trips over [`RelayTunnel`]s (see `tunnel.rs`).
1481///
1482/// `a` opens tunnels targeting `b_id`; `b` opens tunnels targeting `a_id`.
1483#[cfg(test)]
1484pub(crate) fn loopback_reservation_pair(
1485    a_id: &str,
1486    b_id: &str,
1487) -> (Arc<RelayStatus>, Arc<RelayStatus>) {
1488    let a = RelayStatus::new();
1489    let b = RelayStatus::new();
1490    a.set_connected(1);
1491    b.set_connected(1);
1492
1493    let (a_tx, mut a_rx) = mpsc::unbounded_channel::<RelayMessage>();
1494    let (b_tx, mut b_rx) = mpsc::unbounded_channel::<RelayMessage>();
1495    a.set_transport(a_id, DEFAULT_NETWORK_ID, a_tx);
1496    b.set_transport(b_id, DEFAULT_NETWORK_ID, b_tx);
1497
1498    // Drain a's outbound → forward into b (route by `from`), and symmetrically b → a. This is the
1499    // relay's forwarding role, in-process.
1500    let b_route = Arc::clone(&b);
1501    tokio::spawn(async move {
1502        while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = a_rx.recv().await {
1503            b_route.route_relayed(&from, payload);
1504        }
1505    });
1506    let a_route = Arc::clone(&a);
1507    tokio::spawn(async move {
1508        while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = b_rx.recv().await {
1509            a_route.route_relayed(&from, payload);
1510        }
1511    });
1512
1513    (a, b)
1514}
1515
1516/// Serialize + send one `RelayMessage` as a WebSocket text frame.
1517async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
1518where
1519    W: SinkExt<Message> + Unpin,
1520    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1521{
1522    let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
1523    write
1524        .send(Message::Text(txt))
1525        .await
1526        .map_err(|e| format!("send: {e}"))
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531    use super::*;
1532    use dig_ip::Family;
1533    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
1534    use std::sync::Mutex as StdMutex;
1535
1536    // -- #1932: a per-request relay error must not cost us the reservation -------------------
1537
1538    // -- #1935: RLY-009 aggregated DHT-record answers -----------------------------------------
1539
1540    #[test]
1541    fn a_node_answers_nothing_until_a_reader_is_registered() {
1542        // The opted-out default. A node that has not opted in must be indistinguishable on the wire
1543        // from a pre-RLY-009 one — silence, not an empty answer, and certainly not an error.
1544        let status = RelayStatus::new();
1545        assert!(
1546            status.dht_records_provider().is_none(),
1547            "no reader by default"
1548        );
1549    }
1550
1551    #[test]
1552    fn a_registered_reader_receives_the_relays_bound_and_its_answer_is_returned() {
1553        // The bound must reach the reader: the provider store is attacker-influenced, so an answer
1554        // that ignored max_keys would let a Sybil dictate the frame size on the reservation socket.
1555        let status = RelayStatus::new();
1556        let seen: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1557        let seen_w = Arc::clone(&seen);
1558        status.set_dht_records_provider(move |max_keys| {
1559            seen_w.lock().unwrap().push(max_keys);
1560            DhtRecordsAnswer {
1561                records: vec![crate::wire::DhtRecordEntry {
1562                    content_key: "cd".repeat(32),
1563                    providers: 4,
1564                }],
1565                total_keys: 9,
1566                truncated: true,
1567            }
1568        });
1569
1570        let reader = status.dht_records_provider().expect("reader registered");
1571        let answer = reader(16);
1572
1573        assert_eq!(
1574            *seen.lock().unwrap(),
1575            vec![16],
1576            "the bound reached the reader"
1577        );
1578        assert_eq!(answer.total_keys, 9);
1579        assert!(answer.truncated);
1580        assert_eq!(answer.records[0].providers, 4);
1581    }
1582
1583    #[test]
1584    fn the_answer_carries_counts_and_no_identity() {
1585        // Same property the wire test pins, asserted at the type level so it holds for any caller
1586        // constructing an answer, not only for the one serialized shape.
1587        let answer = DhtRecordsAnswer {
1588            records: vec![crate::wire::DhtRecordEntry {
1589                content_key: "ef".repeat(32),
1590                providers: 2,
1591            }],
1592            total_keys: 1,
1593            truncated: false,
1594        };
1595        let rendered = format!("{answer:?}");
1596        assert!(
1597            !rendered.contains("peer_id"),
1598            "no identity field: {rendered}"
1599        );
1600    }
1601
1602    #[test]
1603    fn peer_not_found_is_not_fatal_because_it_is_about_another_peer() {
1604        // The exact frame that was flapping the fleet: the peer we dialled had left the relay.
1605        // Routine on a live network, and no statement at all about our own registration.
1606        assert!(!relay_error_is_fatal(3));
1607    }
1608
1609    #[test]
1610    fn a_bad_frame_we_sent_costs_that_frame_not_the_reservation() {
1611        assert!(!relay_error_is_fatal(2));
1612    }
1613
1614    #[test]
1615    fn every_code_that_means_our_registration_failed_is_fatal() {
1616        // 1 NOT_REGISTERED, and the four that accompany a failing register_ack. For these the
1617        // reservation genuinely does not exist, so ending the loop and re-registering is correct.
1618        for code in [1, 4, 5, 6, 7] {
1619            assert!(
1620                relay_error_is_fatal(code),
1621                "code {code} invalidates the reservation and must end the loop"
1622            );
1623        }
1624    }
1625
1626    #[test]
1627    fn an_unknown_future_code_keeps_the_reservation() {
1628        // Deliberate asymmetry: a wrong guess here costs one log line, whereas defaulting to fatal
1629        // would let a newly-introduced code take every node off its relay simultaneously.
1630        for code in [0, 8, 99, u32::MAX] {
1631            assert!(!relay_error_is_fatal(code), "code {code} must not be fatal");
1632        }
1633    }
1634
1635    #[test]
1636    fn parses_wss_host_and_explicit_port() {
1637        let ep = parse_relay_endpoint("wss://relay.dig.net:443").unwrap();
1638        assert_eq!(ep.host, "relay.dig.net");
1639        assert_eq!(ep.port, 443);
1640    }
1641
1642    #[test]
1643    fn parses_default_ports_by_scheme() {
1644        assert_eq!(
1645            parse_relay_endpoint("wss://relay.dig.net").unwrap().port,
1646            443
1647        );
1648        assert_eq!(parse_relay_endpoint("ws://relay.dig.net").unwrap().port, 80);
1649    }
1650
1651    #[test]
1652    fn parses_bracketed_ipv6_authority_with_and_without_port() {
1653        let with_port = parse_relay_endpoint("wss://[2001:db8::1]:8443").unwrap();
1654        assert_eq!(with_port.host, "2001:db8::1");
1655        assert_eq!(with_port.port, 8443);
1656        let no_port = parse_relay_endpoint("wss://[2001:db8::1]/ws").unwrap();
1657        assert_eq!(no_port.host, "2001:db8::1");
1658        assert_eq!(no_port.port, 443);
1659    }
1660
1661    #[test]
1662    fn ignores_path_query_and_userinfo() {
1663        let ep = parse_relay_endpoint("wss://user@relay.dig.net:9443/ws?x=1#f").unwrap();
1664        assert_eq!(ep.host, "relay.dig.net");
1665        assert_eq!(ep.port, 9443);
1666    }
1667
1668    #[test]
1669    fn rejects_malformed_endpoints() {
1670        assert!(parse_relay_endpoint("relay.dig.net:443").is_err()); // no scheme
1671        assert!(parse_relay_endpoint("http://relay.dig.net").is_err()); // wrong scheme
1672        assert!(parse_relay_endpoint("wss://relay.dig.net:notaport").is_err());
1673    }
1674
1675    #[tokio::test]
1676    async fn resolve_relay_candidates_handles_ip_literals_without_dns() {
1677        let v6 = resolve_relay_candidates("2001:db8::1", 443).await.unwrap();
1678        assert_eq!(v6.all().len(), 1);
1679        assert_eq!(v6.all()[0].family, Family::V6);
1680        assert_eq!(v6.all()[0].source, CandidateSource::DnsAAAA);
1681
1682        let v4 = resolve_relay_candidates("203.0.113.7", 443).await.unwrap();
1683        assert_eq!(v4.all()[0].family, Family::V4);
1684        assert_eq!(v4.all()[0].source, CandidateSource::DnsA);
1685    }
1686
1687    /// The relay dial races BOTH families and falls back to IPv4 when the IPv6 candidate is dead —
1688    /// the §5.2 happy-eyeballs guarantee, proven with a FAKE dial closure (no real DNS/sockets). A
1689    /// dead IPv6 candidate + a live IPv4 candidate on a dual-stack host must yield the IPv4 winner,
1690    /// and BOTH families must have been attempted.
1691    #[tokio::test]
1692    async fn relay_dial_races_both_families_and_falls_back_to_ipv4() {
1693        let mut candidates = PeerCandidates::new();
1694        let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1695        let v4: SocketAddr = "203.0.113.7:443".parse().unwrap();
1696        candidates.add(v6, CandidateSource::DnsAAAA);
1697        candidates.add(v4, CandidateSource::DnsA);
1698
1699        let dual = LocalStack::from_flags(true, true);
1700        let attempted: StdMutex<Vec<SocketAddr>> = StdMutex::new(Vec::new());
1701        // Fast attempt-delay so the hedged IPv4 starts promptly once the IPv6 attempt fails.
1702        let cfg = DialConfig {
1703            per_attempt_timeout: Duration::from_secs(1),
1704            attempt_delay: Duration::from_millis(5),
1705        };
1706
1707        let winner = race_relay_candidates(&dual, &candidates, cfg, |addr| {
1708            let attempted = &attempted;
1709            async move {
1710                attempted.lock().unwrap().push(addr);
1711                if addr.is_ipv6() {
1712                    Err(format!("simulated dead IPv6 {addr}"))
1713                } else {
1714                    Ok(addr) // the fake "connection" is just the address that won
1715                }
1716            }
1717        })
1718        .await
1719        .expect("IPv4 fallback wins when IPv6 is dead");
1720
1721        assert_eq!(winner.conn, v4, "the live IPv4 candidate won");
1722        assert_eq!(winner.family, Family::V4);
1723        let tried = attempted.lock().unwrap();
1724        assert!(
1725            tried.contains(&v6),
1726            "the IPv6 candidate was attempted first"
1727        );
1728        assert!(
1729            tried.contains(&v4),
1730            "the IPv4 candidate was attempted as fallback"
1731        );
1732    }
1733
1734    /// IPv6 is the PREFERENCE, not merely first-attempted: with both families live on a dual-stack
1735    /// host, the IPv6 candidate wins the race (IPv4 is only a fallback).
1736    #[tokio::test]
1737    async fn relay_dial_prefers_ipv6_when_both_live() {
1738        let mut candidates = PeerCandidates::new();
1739        let v6: SocketAddr = "[2001:db8::2]:443".parse().unwrap();
1740        let v4: SocketAddr = "203.0.113.8:443".parse().unwrap();
1741        candidates.add(v6, CandidateSource::DnsAAAA);
1742        candidates.add(v4, CandidateSource::DnsA);
1743
1744        let dual = LocalStack::from_flags(true, true);
1745        let calls = AtomicUsize::new(0);
1746        let winner = race_relay_candidates(&dual, &candidates, DialConfig::default(), |addr| {
1747            calls.fetch_add(1, AtomicOrdering::Relaxed);
1748            async move { Ok::<SocketAddr, String>(addr) }
1749        })
1750        .await
1751        .unwrap();
1752
1753        assert_eq!(winner.conn, v6, "IPv6 preferred when both are viable");
1754        assert_eq!(winner.family, Family::V6);
1755    }
1756
1757    /// A minimal TLS handshake record whose first message is a ClientHello — the ONLY frame that opens
1758    /// an introduced circuit (#1761), so every test that drives the responder path must send this shape.
1759    fn client_hello_frame() -> Vec<u8> {
1760        vec![0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0, 0, 0, 0]
1761    }
1762
1763    /// Build a `Connected` status with a live (dummy) outbound sink + local identity, so
1764    /// `route_relayed`'s introduced-circuit path can run without a real relay socket.
1765    fn connected_status(local_id: &str) -> Arc<RelayStatus> {
1766        let status = RelayStatus::new();
1767        status.set_connected(1);
1768        let (out_tx, _out_rx) = mpsc::unbounded_channel::<RelayMessage>();
1769        status.set_transport(local_id, DEFAULT_NETWORK_ID, out_tx);
1770        // These tests exercise the introduced-circuit ROUTING (register/accept/drop), which never uses
1771        // the outbound sink, so the receiver may drop at end of scope — the sink stays `Some`.
1772        status
1773    }
1774
1775    /// SECURITY (accept OFF): with NO responder path enabled, an introduced RLY-002 frame from an
1776    /// unknown peer is DROPPED — no tunnel is created and nothing is surfaced. This is the
1777    /// untrusted-relay default: a node that never opted into accepting relayed circuits cannot be made
1778    /// to spawn one by a hostile relay.
1779    #[test]
1780    fn introduced_frame_dropped_when_accept_disabled() {
1781        let status = connected_status("00aa");
1782        // A ClientHello-shaped frame from a peer we hold NO tunnel to — the introduced-circuit trigger.
1783        status.route_relayed("ffbb", client_hello_frame());
1784        assert!(
1785            !status.open_tunnel_exists("ffbb"),
1786            "no tunnel surfaced for an introduced circuit while accept is off"
1787        );
1788        assert_eq!(
1789            status.tunnels.lock().unwrap().len(),
1790            0,
1791            "accept-off drops the introduced circuit entirely"
1792        );
1793    }
1794
1795    /// REGRESSION (#1871): a STALE relay circuit — one registered but silent past
1796    /// [`STALE_CIRCUIT_IDLE`] — must NOT suppress a fresh outbound dial to that peer.
1797    ///
1798    /// Measured on two real hosts with direct connectivity physically impossible: the relayed tier
1799    /// returned `existing relay circuit to peer — not opening a duplicate` at `elapsed_ms=0` while the
1800    /// node's peer pool reported `connected_peers: 0, peers: []`. A server-role registration whose
1801    /// mTLS session never came up is invisible to the pool yet immortal in the tunnel table, so the
1802    /// last-resort tier stayed permanently suppressed for that peer.
1803    ///
1804    /// The replacement must also TAKE the key as the dialer's own CLIENT-role circuit — a fix that
1805    /// returned a tunnel while leaving the phantom server entry in place would report success and
1806    /// still route inbound frames into the dead session.
1807    #[test]
1808    fn stale_relayed_circuit_does_not_suppress_a_fresh_dial() {
1809        let status = connected_status("00aa");
1810        // A phantom: a server-role circuit accepted from an introduced frame whose session never came
1811        // up. Its `RelayTunnel` is still held (a stuck accept task), so `Drop` never deregistered it.
1812        let phantom = status.register_tunnel("ffbb", DEFAULT_NETWORK_ID, TunnelRole::Server);
1813        status.backdate_tunnel("ffbb", STALE_CIRCUIT_IDLE + Duration::from_secs(1));
1814
1815        let fresh = status
1816            .open_tunnel("ffbb", DEFAULT_NETWORK_ID)
1817            .expect("a circuit idle past the stale window must not suppress the relayed tier");
1818
1819        assert_eq!(
1820            status.tunnel_role("ffbb"),
1821            Some(TunnelRole::Client),
1822            "the fresh dial must OWN the key as the mTLS client — leaving the phantom server entry              registered would keep routing inbound frames into the dead session"
1823        );
1824        drop(fresh);
1825        drop(phantom);
1826    }
1827
1828    /// CONTROL for [`stale_relayed_circuit_does_not_suppress_a_fresh_dial`]: a LIVE circuit still
1829    /// refuses a duplicate. This is the #1536 glare defense, and it is what separates the #1871 fix
1830    /// from simply deleting the guard — a timing-ordered glare resolves within a handshake RTT, far
1831    /// inside [`STALE_CIRCUIT_IDLE`], so the second dial must still be refused.
1832    #[test]
1833    fn live_relayed_circuit_still_refuses_a_duplicate_dial() {
1834        let status = connected_status("00aa");
1835        let held = status.register_tunnel("ffbb", DEFAULT_NETWORK_ID, TunnelRole::Server);
1836
1837        let Err(err) = status.open_tunnel("ffbb", DEFAULT_NETWORK_ID) else {
1838            panic!("a freshly-registered circuit is a glare, not a phantom — refuse the dial")
1839        };
1840        assert!(
1841            err.contains("not opening a duplicate"),
1842            "the glare refusal must keep its own reason: {err}"
1843        );
1844        assert_eq!(
1845            status.tunnel_role("ffbb"),
1846            Some(TunnelRole::Server),
1847            "the live server circuit keeps the key"
1848        );
1849        drop(held);
1850    }
1851
1852    /// An inbound frame REFRESHES a circuit, so liveness is measured from last activity and not from
1853    /// registration time. Without this, a long-lived healthy relayed session would age into
1854    /// "stale" and be clobbered by the next dial — reintroducing the #1536 double-session under a
1855    /// different trigger.
1856    #[test]
1857    fn inbound_traffic_refreshes_a_circuit_so_it_is_not_stale() {
1858        let status = connected_status("00aa");
1859        let held = status.register_tunnel("ffbb", DEFAULT_NETWORK_ID, TunnelRole::Server);
1860        status.backdate_tunnel("ffbb", STALE_CIRCUIT_IDLE + Duration::from_secs(1));
1861
1862        // One inbound application frame on the server-role circuit — the peer is demonstrably alive.
1863        // (Not a ClientHello: a server-role tunnel routes any frame straight through as the client's.)
1864        status.route_relayed("ffbb", vec![0x17, 0x03, 0x03, 0x00, 0x01, 0x00]);
1865
1866        let Err(err) = status.open_tunnel("ffbb", DEFAULT_NETWORK_ID) else {
1867            panic!(
1868                "a circuit that just carried a frame is LIVE — staleness must be measured from                  last activity, not from registration"
1869            )
1870        };
1871        assert!(
1872            err.contains("not opening a duplicate"),
1873            "a refreshed circuit refuses the duplicate for the glare reason: {err}"
1874        );
1875        drop(held);
1876    }
1877
1878    /// SECURITY (flood defense, tunnel cap): once [`MAX_RELAY_TUNNELS`] tunnels are open, a further
1879    /// introduced circuit from a new peer is DROPPED rather than registered — a hostile relay flooding
1880    /// distinct fabricated `from` ids cannot spawn unbounded server tunnels/accept-tasks.
1881    #[test]
1882    fn introduced_circuit_dropped_at_max_tunnels_cap() {
1883        let status = connected_status("00aa");
1884        let mut accept_rx = status.enable_accept();
1885        // Saturate the tunnel table at the cap (held open by the returned RelayTunnels).
1886        let mut held = Vec::new();
1887        for i in 0..MAX_RELAY_TUNNELS {
1888            held.push(status.register_tunnel(
1889                &format!("peer{i:05}"),
1890                DEFAULT_NETWORK_ID,
1891                TunnelRole::Server,
1892            ));
1893        }
1894        assert_eq!(status.tunnels.lock().unwrap().len(), MAX_RELAY_TUNNELS);
1895
1896        status.route_relayed("overflowpeer", client_hello_frame());
1897        assert!(
1898            !status.open_tunnel_exists("overflowpeer"),
1899            "an introduced circuit beyond the tunnel cap is dropped, not registered"
1900        );
1901        assert_eq!(
1902            status.tunnels.lock().unwrap().len(),
1903            MAX_RELAY_TUNNELS,
1904            "tunnel count never grows past the cap"
1905        );
1906        assert!(
1907            accept_rx.try_recv().is_err(),
1908            "the capped circuit is never surfaced to the acceptor"
1909        );
1910        drop(held);
1911    }
1912
1913    /// SECURITY (flood defense, accept channel): when the bounded inbound-accept channel
1914    /// ([`INBOUND_ACCEPT_CAP`]) is full — the consumer is not accepting fast enough — a further
1915    /// introduced circuit is DROPPED (its freshly-registered tunnel is torn down), bounded
1916    /// backpressure rather than unbounded queueing.
1917    #[test]
1918    fn introduced_circuit_dropped_when_accept_channel_full() {
1919        let status = connected_status("00aa");
1920        let mut accept_rx = status.enable_accept();
1921        // Fill the accept channel to capacity WITHOUT draining it — each surfaced circuit occupies one
1922        // slot and keeps its server tunnel registered (the RelayTunnel lives in the channel).
1923        for i in 0..INBOUND_ACCEPT_CAP {
1924            status.route_relayed(&format!("in{i:05}"), client_hello_frame());
1925        }
1926        assert_eq!(
1927            status.tunnels.lock().unwrap().len(),
1928            INBOUND_ACCEPT_CAP,
1929            "each surfaced circuit registered exactly one server tunnel"
1930        );
1931
1932        // One more: the accept channel is full → the tunnel is registered then immediately dropped,
1933        // so its routing is deregistered and nothing new is surfaced.
1934        status.route_relayed("overflow", client_hello_frame());
1935        assert!(
1936            !status.open_tunnel_exists("overflow"),
1937            "an introduced circuit is dropped when the accept channel is full"
1938        );
1939
1940        let mut surfaced = 0;
1941        while accept_rx.try_recv().is_ok() {
1942            surfaced += 1;
1943        }
1944        assert_eq!(
1945            surfaced, INBOUND_ACCEPT_CAP,
1946            "exactly the channel capacity surfaced — never the overflow circuit"
1947        );
1948    }
1949
1950    /// REGRESSION (#1536 glare, TIMING order): a peer's ClientHello arrives BEFORE our own dial to it
1951    /// registers. We accept it as a server; our later dial to the SAME peer MUST then be refused
1952    /// (non-clobber) so no conflicting second circuit / double mTLS session is created. This is the
1953    /// deeper ordering the first tie-break missed (role was decided by who-registered-first).
1954    #[test]
1955    fn clienthello_before_local_dial_does_not_double_register() {
1956        let status = connected_status("bbbb");
1957        let mut accept_rx = status.enable_accept();
1958
1959        // The peer's introduced ClientHello arrives first → we accept it as a server-role circuit.
1960        status.route_relayed("aaaa", client_hello_frame());
1961        assert!(status.open_tunnel_exists("aaaa"));
1962        let _server_tunnel = accept_rx
1963            .try_recv()
1964            .expect("introduced circuit surfaced as a server");
1965
1966        // Our own dial to that peer now must be REFUSED — the existing circuit is the connection.
1967        let dial = status.open_tunnel("aaaa", DEFAULT_NETWORK_ID);
1968        assert!(
1969            dial.is_err(),
1970            "a second dial to a peer we already serve is refused (no double-session)"
1971        );
1972        assert_eq!(
1973            status.tunnels.lock().unwrap().len(),
1974            1,
1975            "exactly one circuit per peer — never a conflicting client+server pair"
1976        );
1977    }
1978
1979    /// REGRESSION (#1536 equal-id): a relayed self-dial, or a frame stamped with our OWN peer_id
1980    /// (theoretical SPKI collision / a hostile relay reflecting our id), has no lower/higher end for
1981    /// the tie-break — it MUST be rejected outright, never producing a no-server hang.
1982    #[test]
1983    fn self_dial_and_self_stamped_frame_rejected() {
1984        let status = connected_status("cccc");
1985        assert!(
1986            status.open_tunnel("cccc", DEFAULT_NETWORK_ID).is_err(),
1987            "a relayed self-dial (target == local id) is refused"
1988        );
1989
1990        let mut accept_rx = status.enable_accept();
1991        status.route_relayed("cccc", client_hello_frame());
1992        assert!(
1993            !status.open_tunnel_exists("cccc"),
1994            "a frame stamped with our own id is dropped, never registered"
1995        );
1996        assert!(
1997            accept_rx.try_recv().is_err(),
1998            "a self-stamped frame is never surfaced as an accept"
1999        );
2000    }
2001
2002    /// SECURITY (#1536 relay-injected-ClientHello DoS): an untrusted relay can inject a bogus
2003    /// ClientHello on a lower-id node's client tunnel to force it to yield its outbound dial to a
2004    /// server accept that no real peer completes. mTLS identity is never bypassed, but the outbound
2005    /// dial MUST NOT be permanently lost — once the bogus (never-completing) server circuit is dropped,
2006    /// the peer key frees and a fresh dial is possible.
2007    #[test]
2008    fn injected_clienthello_yield_does_not_permanently_block_redial() {
2009        // local id "00aa" is numerically lower than the peer "ffff", so we are the yield-to-server side.
2010        let status = connected_status("00aa");
2011        let mut accept_rx = status.enable_accept();
2012
2013        let client_tunnel = status
2014            .open_tunnel("ffff", DEFAULT_NETWORK_ID)
2015            .expect("outbound relayed dial opens");
2016        assert!(status.open_tunnel_exists("ffff"));
2017
2018        // The relay injects a bogus ClientHello with from=ffff → glare on our client tunnel → we (the
2019        // lower id) yield: drop the client tunnel, surface a server accept.
2020        status.route_relayed("ffff", client_hello_frame());
2021        let server_tunnel = accept_rx
2022            .try_recv()
2023            .expect("the injected ClientHello yielded a server accept");
2024
2025        // The original outbound dial is cancelled; dropping its handle must NOT evict the newer server
2026        // entry (generation-id guard).
2027        drop(client_tunnel);
2028        assert!(
2029            status.open_tunnel_exists("ffff"),
2030            "the server circuit survives the cancelled client dial's drop"
2031        );
2032
2033        // The bogus circuit completes no handshake; once its tunnel is dropped the key frees...
2034        drop(server_tunnel);
2035        assert!(
2036            !status.open_tunnel_exists("ffff"),
2037            "dropping the never-completing server circuit releases the peer key"
2038        );
2039        // ...and a fresh dial is possible — no permanent lockout from the injected frame.
2040        assert!(
2041            status.open_tunnel("ffff", DEFAULT_NETWORK_ID).is_ok(),
2042            "a fresh outbound dial succeeds after the bogus injection is cleaned up"
2043        );
2044    }
2045
2046    /// REGRESSION (#1761): only a client's OPENING HANDSHAKE may create a server-role circuit.
2047    ///
2048    /// A relayed frame from a peer we hold no tunnel to used to be accepted as an introduced circuit
2049    /// WHATEVER its content, so any frame that is not a dialer's ClientHello manufactured a bogus
2050    /// server-role circuit — and the mTLS server behind it then failed with the live
2051    /// `got ServerHello when expecting ClientHello` (both ends running the TLS server role).
2052    ///
2053    /// The frames covered here are the whole CLASS of "not the start of an inbound circuit", each with
2054    /// a real production or adversarial origin: a peer's ServerHello or application record arriving
2055    /// after we released our client tunnel (a `fast_connect` relayed→direct promotion drops the
2056    /// per-peer tunnel while the peer's frames are still in flight), a truncated record too short to
2057    /// classify, and arbitrary relay garbage. The final ClientHello is the truthful CONTROL: the
2058    /// responder path still works, and dropping the stray frames never blacklists the peer.
2059    #[test]
2060    fn only_a_clienthello_creates_an_introduced_circuit() {
2061        let status = connected_status("00aa");
2062        let mut accept_rx = status.enable_accept();
2063
2064        // A TLS handshake record whose message type is ServerHello (0x02) — the live #1761 frame.
2065        let server_hello = vec![0x16, 0x03, 0x03, 0x00, 0x05, 0x02, 0, 0, 0, 0];
2066        // A TLS application-data record (0x17) — a mid-session frame from a released tunnel.
2067        let app_data = vec![0x17, 0x03, 0x03, 0x00, 0x04, 0xde, 0xad, 0xbe, 0xef];
2068        // A record header truncated before the handshake-message byte — unclassifiable, so not an
2069        // opening handshake.
2070        let truncated = vec![0x16, 0x03, 0x03, 0x00, 0x05];
2071        // Not TLS at all.
2072        let garbage = vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
2073
2074        for (label, frame) in [
2075            ("ServerHello", server_hello),
2076            ("application record", app_data),
2077            ("truncated record", truncated),
2078            ("garbage", garbage),
2079        ] {
2080            status.route_relayed("ffbb", frame);
2081            assert!(
2082                !status.open_tunnel_exists("ffbb"),
2083                "a {label} frame must not register a server-role circuit"
2084            );
2085            assert!(
2086                accept_rx.try_recv().is_err(),
2087                "a {label} frame must not surface an accept"
2088            );
2089        }
2090
2091        // CONTROL: the peer's genuine ClientHello still opens the circuit — the responder path is
2092        // intact and the earlier drops left no per-peer state behind.
2093        status.route_relayed("ffbb", client_hello_frame());
2094        assert!(
2095            status.open_tunnel_exists("ffbb"),
2096            "a genuine ClientHello still opens an introduced circuit"
2097        );
2098        assert!(
2099            accept_rx.try_recv().is_ok(),
2100            "a genuine ClientHello still surfaces an accept"
2101        );
2102    }
2103}