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;
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/// The mTLS role a locally-registered [`RelayTunnel`] runs — the discriminator that resolves the
94/// GLARE / simultaneous-mutual-dial case (#1536). A relay circuit needs exactly ONE mTLS client + ONE
95/// mTLS server; when two NAT'd peers fall to the relay tier and dial EACH OTHER at the same time (the
96/// common two-NAT'd-peer flywheel case), both open a `Client` tunnel and both send a ClientHello —
97/// each ClientHello would route into the OTHER side's client session (double-ClientHello deadlock).
98/// Tagging the tunnel with its role lets [`route_relayed`](RelayStatus::route_relayed) detect that a
99/// ClientHello arrived on a tunnel where WE are also the client (the glare signature) and apply the
100/// deterministic tie-break instead of feeding it to our doomed client.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum TunnelRole {
103    /// WE initiated the dial — running [`PeerSession::client`](crate::mux::PeerSession::client) over
104    /// this tunnel; inbound frames are the peer's ServerHello / server-side records.
105    Client,
106    /// WE accepted an introduced circuit — running [`PeerSession::server`](crate::mux::PeerSession::server)
107    /// over this tunnel; inbound frames are the dialing peer's client-side records.
108    Server,
109}
110
111/// A registered relayed tunnel: the inbound sink frames are routed into, the mTLS [`TunnelRole`] we
112/// run over it (for the #1536 glare tie-break), and a monotonic `id` distinguishing this registration
113/// from a later one on the SAME peer key. The id matters when the glare tie-break REPLACES our client
114/// tunnel with a server tunnel under the same `from` key: the old client [`RelayTunnel`]'s `Drop`
115/// (fired when its doomed dial fails) must not deregister the NEW server entry, so `close_tunnel` only
116/// removes when the stored id still matches.
117#[derive(Debug)]
118struct TunnelEntry {
119    sink: mpsc::Sender<Vec<u8>>,
120    role: TunnelRole,
121    id: u64,
122}
123
124/// Whether `payload` begins with a TLS handshake record whose first message is a ClientHello — a TLS
125/// record has content-type `0x16` (handshake) at byte 0 and the handshake message type at byte 5
126/// (`0x01` = ClientHello, `0x02` = ServerHello). A rustls client ships its ClientHello flight as the
127/// first `poll_write`, so the first relayed frame from a fresh dialer matches this. Used ONLY to
128/// distinguish a peer's GLARE ClientHello (a competing simultaneous dial) from the ServerHello / app
129/// records expected on a tunnel where we are the client (#1536).
130fn is_tls_client_hello(payload: &[u8]) -> bool {
131    payload.len() >= 6 && payload[0] == 0x16 && payload[5] == 0x01
132}
133
134/// Compute the next reconnect backoff: capped exponential in the number of consecutive failures.
135/// `failures == 0` → base; doubles each failure up to [`MAX_BACKOFF_SECS`]. Pure → unit-tested.
136pub fn backoff_secs(consecutive_failures: u32) -> u64 {
137    backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
138}
139
140/// Capped-exponential backoff with an explicit base + cap. Always returns a value in `[base, cap]`
141/// — never zero — so a failing connect can never busy-loop.
142fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
143    let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
144    shifted.clamp(base, cap)
145}
146
147/// Backoff schedule for the reconnect loop — production defaults, or fast values for tests.
148#[derive(Debug, Clone, Copy)]
149pub struct Backoff {
150    /// First-retry delay (seconds).
151    pub base_secs: u64,
152    /// Upper bound on the delay (seconds).
153    pub cap_secs: u64,
154}
155
156impl Default for Backoff {
157    fn default() -> Self {
158        Backoff {
159            base_secs: BASE_BACKOFF_SECS,
160            cap_secs: MAX_BACKOFF_SECS,
161        }
162    }
163}
164
165/// The four observable states of the relay reservation, surfaced verbatim (lowercase) as the
166/// `state` field of a `control.relayStatus`-style RPC.
167///
168/// - `Disabled` — reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
169/// - `Connecting` — actively dialing/registering.
170/// - `Connected` — a reservation is held (`RegisterAck{success:true}` arrived); reachable to peers.
171/// - `Disconnected` — not connected; backing off + will retry. The graceful-fallback resting state.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum RelayState {
174    /// Reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
175    Disabled,
176    /// Actively dialing/registering (initial attempt or a reconnect in flight).
177    Connecting,
178    /// A reservation is held (`RegisterAck{success:true}` arrived); reachable to NAT'd peers.
179    Connected,
180    /// Not connected; backing off + will retry. The graceful-fallback resting state.
181    Disconnected,
182}
183
184impl RelayState {
185    /// The stable lowercase wire string for the RPC `state` field.
186    pub fn as_str(self) -> &'static str {
187        match self {
188            RelayState::Disabled => "disabled",
189            RelayState::Connecting => "connecting",
190            RelayState::Connected => "connected",
191            RelayState::Disconnected => "disconnected",
192        }
193    }
194
195    fn to_u8(self) -> u8 {
196        match self {
197            RelayState::Disabled => 0,
198            RelayState::Connecting => 1,
199            RelayState::Connected => 2,
200            RelayState::Disconnected => 3,
201        }
202    }
203
204    fn from_u8(v: u8) -> Self {
205        match v {
206            0 => RelayState::Disabled,
207            1 => RelayState::Connecting,
208            2 => RelayState::Connected,
209            _ => RelayState::Disconnected,
210        }
211    }
212}
213
214/// The peers discovered over the live reservation socket, in insertion order with O(1) dedup +
215/// membership by `peer_id`, bounded to [`MAX_KNOWN_PEERS`].
216///
217/// `order` preserves discovery order so [`RelayStatus::known_peers`] returns a stable sequence;
218/// `ids` mirrors `order`'s `peer_id`s so dedup and removal are O(1) instead of a linear scan (the
219/// old `iter().any(...)` was O(n²) over a flood). The two are kept in lockstep — every mutation
220/// touches both.
221#[derive(Debug, Default)]
222struct DiscoveredPeers {
223    order: Vec<RelayPeerInfo>,
224    ids: HashSet<String>,
225}
226
227impl DiscoveredPeers {
228    /// Insert `peer` unless already present or the set is full. Returns nothing — a full set simply
229    /// drops the newcomer (the untrusted-relay flood defense).
230    fn insert(&mut self, peer: RelayPeerInfo) {
231        if self.order.len() >= MAX_KNOWN_PEERS {
232            return;
233        }
234        if self.ids.insert(peer.peer_id.clone()) {
235            self.order.push(peer);
236        }
237    }
238
239    /// Remove the peer with this `peer_id`, if present.
240    fn remove(&mut self, peer_id: &str) {
241        if self.ids.remove(peer_id) {
242            self.order.retain(|p| p.peer_id != peer_id);
243        }
244    }
245
246    /// Replace the whole set from a `Peers` frame, deduped + truncated to the cap.
247    fn replace(&mut self, peers: Vec<RelayPeerInfo>) {
248        self.order.clear();
249        self.ids.clear();
250        for peer in peers {
251            self.insert(peer);
252        }
253    }
254
255    fn clear(&mut self) {
256        self.order.clear();
257        self.ids.clear();
258    }
259}
260
261/// Live relay-connection status, shared (via `Arc`) between the connection task and an RPC handler.
262/// Cheap atomic reads. State setters do STATE-CHANGE-ONLY logging so a long outage never hot-loops
263/// identical error lines.
264#[derive(Debug)]
265pub struct RelayStatus {
266    state: AtomicU8,
267    reconnect_attempts: AtomicU32,
268    connected_peers: AtomicU64,
269    last_error: Mutex<Option<String>>,
270    /// Peers learned over the LIVE reservation socket — the relay's `GetPeers` response (RLY-005)
271    /// plus `PeerConnected`/`PeerDisconnected` pushes. This is the discovery output of the persistent
272    /// reservation: a consumer (dig-gossip's pool/address book) reads it instead of reopening an
273    /// ephemeral socket per pass. Keyed by `peer_id` (deduped); bounded to [`MAX_KNOWN_PEERS`] so an
274    /// untrusted relay can't exhaust memory; cleared on every reconnect so a stale list is never
275    /// served across a drop.
276    known_peers: Mutex<DiscoveredPeers>,
277    /// Sink that injects an outbound [`RelayMessage`] into the LIVE reservation socket's write half.
278    /// `Some` only while a session is held (set by `connect_once`, cleared on every drop) — this is
279    /// what lets a [`RelayTunnel`] reuse the ONE persistent reservation socket for RLY-002 relayed
280    /// transport instead of opening a second connection.
281    outbound: Mutex<Option<mpsc::UnboundedSender<RelayMessage>>>,
282    /// This node's own `peer_id` (hex), stamped as `from` on every RLY-002 frame the tunnels send.
283    /// Set when a session registers; needed because a tunnel is opened from the shared status handle.
284    local_peer_id: Mutex<Option<String>>,
285    /// The network id this reservation registered under. Echoed onto an inbound accepted tunnel (the
286    /// RLY-002 frame itself does not carry it). Set alongside [`local_peer_id`] when a session registers.
287    local_network_id: Mutex<Option<String>>,
288    /// Sink that surfaces an INTRODUCED inbound circuit — a frame from a peer with NO open outbound
289    /// tunnel — as a server-role [`RelayTunnel`] for a consumer to accept + serve
290    /// ([`crate::accept::RelayAcceptor`]). `None` (default) = the original untrusted-relay behavior:
291    /// drop an unknown-peer frame. `Some` once a consumer calls [`RelayStatus::enable_accept`].
292    ///
293    /// This is the RESPONDER counterpart to [`open_tunnel`](Self::open_tunnel): a relay circuit needs
294    /// exactly ONE mTLS client + ONE mTLS server. The DIALER calls `open_tunnel` and runs
295    /// `PeerSession::client`; the reservation-HOLDER that RECEIVES the introduced circuit accepts here
296    /// and runs `PeerSession::server`. Without this path both ends acted as TLS client and the
297    /// handshake deadlocked (`got ClientHello when expecting ServerHello`, #1536).
298    inbound_accept: Mutex<Option<mpsc::Sender<RelayTunnel>>>,
299    /// Open relayed-transport tunnels, keyed by the REMOTE peer's `peer_id` (hex). An inbound RLY-002
300    /// `relay_message` from a peer is routed to its tunnel's inbound channel; a frame from a peer with
301    /// no open tunnel is dropped (the untrusted-relay default). Entries are removed on tunnel drop.
302    /// Each entry carries the mTLS [`TunnelRole`] we run over it so `route_relayed` can resolve the
303    /// #1536 simultaneous-mutual-dial glare deterministically.
304    tunnels: Mutex<HashMap<String, TunnelEntry>>,
305    /// Monotonic per-node sequence number stamped on outbound RLY-002 frames (ordering/dedup).
306    relay_seq: AtomicU64,
307    /// Monotonic id assigned to each tunnel registration so a stale [`RelayTunnel`]'s `Drop` never
308    /// deregisters a NEWER entry under the same peer key (see [`TunnelEntry::id`]; #1536 glare replace).
309    next_tunnel_id: AtomicU64,
310}
311
312impl Default for RelayStatus {
313    fn default() -> Self {
314        RelayStatus {
315            state: AtomicU8::new(RelayState::Disconnected.to_u8()),
316            reconnect_attempts: AtomicU32::new(0),
317            connected_peers: AtomicU64::new(0),
318            last_error: Mutex::new(None),
319            known_peers: Mutex::new(DiscoveredPeers::default()),
320            outbound: Mutex::new(None),
321            local_peer_id: Mutex::new(None),
322            local_network_id: Mutex::new(None),
323            inbound_accept: Mutex::new(None),
324            tunnels: Mutex::new(HashMap::new()),
325            relay_seq: AtomicU64::new(0),
326            next_tunnel_id: AtomicU64::new(0),
327        }
328    }
329}
330
331impl RelayStatus {
332    /// A fresh status (resting `Disconnected` until the task runs / the relay is reached).
333    pub fn new() -> Arc<Self> {
334        Arc::new(RelayStatus::default())
335    }
336
337    /// Read the current state.
338    pub fn state(&self) -> RelayState {
339        RelayState::from_u8(self.state.load(Ordering::Relaxed))
340    }
341
342    /// Transition to `next`, returning `true` IFF the state actually changed. Callers use the return
343    /// to log ONCE per transition (no hot-loop spam).
344    fn transition_to(&self, next: RelayState) -> bool {
345        let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
346        prev != next.to_u8()
347    }
348
349    /// Enter `Disabled` (reservation off). Idempotent; logs only on the first entry.
350    pub fn set_disabled(&self) {
351        if self.transition_to(RelayState::Disabled) {
352            tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
353        }
354    }
355
356    /// Enter `Connecting`. Logs only on the transition (so reconnect attempts don't spam).
357    pub fn set_connecting(&self) {
358        if self.transition_to(RelayState::Connecting) {
359            tracing::debug!("relay connecting");
360        }
361    }
362
363    /// Mark `Connected` (clears the last error, resets the attempt counter). Logs recovery once.
364    pub fn set_connected(&self, connected_peers: u64) {
365        self.connected_peers
366            .store(connected_peers, Ordering::Relaxed);
367        self.reconnect_attempts.store(0, Ordering::Relaxed);
368        *self.last_error.lock().unwrap() = None;
369        if self.transition_to(RelayState::Connected) {
370            tracing::info!(connected_peers, "relay reservation established");
371        }
372    }
373
374    /// Mark `Disconnected` with an optional error and bump the attempt counter. Logs the failure
375    /// ONLY on the transition into `Disconnected` (the first drop); subsequent failed retries while
376    /// already `Disconnected` update the error/counter SILENTLY.
377    pub fn set_disconnected(&self, error: Option<String>) {
378        self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
379        if let Some(e) = &error {
380            *self.last_error.lock().unwrap() = Some(e.clone());
381        }
382        let changed = self.transition_to(RelayState::Disconnected);
383        if changed {
384            match &error {
385                Some(e) => tracing::warn!(
386                    error = %e,
387                    "relay reservation lost — node still serving; retrying in background"
388                ),
389                None => tracing::info!("relay reservation closed — retrying in background"),
390            }
391        }
392    }
393
394    /// Whether a relay session is currently held.
395    pub fn is_connected(&self) -> bool {
396        self.state() == RelayState::Connected
397    }
398
399    /// The current reconnect-attempt count (for tests / RPC).
400    pub fn reconnect_attempts(&self) -> u32 {
401        self.reconnect_attempts.load(Ordering::Relaxed)
402    }
403
404    /// Snapshot of the peers discovered over the live reservation socket (RLY-005 `Peers` +
405    /// `PeerConnected` pushes, minus `PeerDisconnected`). The consumer folds these into its address
406    /// book / pool. Returns a clone so the caller holds no lock.
407    pub fn known_peers(&self) -> Vec<RelayPeerInfo> {
408        self.known_peers.lock().unwrap().order.clone()
409    }
410
411    /// Count of peers currently discovered over the live reservation socket.
412    pub fn known_peer_count(&self) -> usize {
413        self.known_peers.lock().unwrap().order.len()
414    }
415
416    /// Replace the discovered-peer set with a `GetPeers` response (RLY-005 `Peers`), deduped and
417    /// truncated to [`MAX_KNOWN_PEERS`] (an untrusted relay could send an oversized frame).
418    fn replace_known_peers(&self, peers: Vec<RelayPeerInfo>) {
419        self.known_peers.lock().unwrap().replace(peers);
420    }
421
422    /// Fold in a relay-pushed `PeerConnected` notice, deduped by `peer_id`; dropped once the set is
423    /// full ([`MAX_KNOWN_PEERS`]) so a flood can't exhaust memory.
424    fn add_known_peer(&self, peer: RelayPeerInfo) {
425        self.known_peers.lock().unwrap().insert(peer);
426    }
427
428    /// Drop a peer on a relay-pushed `PeerDisconnected` notice.
429    fn remove_known_peer(&self, peer_id: &str) {
430        self.known_peers.lock().unwrap().remove(peer_id);
431    }
432
433    /// Clear the discovered-peer set (on every reconnect — the list is per-session).
434    fn clear_known_peers(&self) {
435        self.known_peers.lock().unwrap().clear();
436    }
437
438    // -- RLY-002 relayed transport (the tier-6 TURN fallback) ------------------------------------
439    //
440    // A relayed tunnel reuses the ONE persistent reservation socket: outbound frames go through
441    // `outbound` (drained by the reservation loop's write half), inbound `relay_message` frames are
442    // routed by `from` peer_id to the matching tunnel. Available only while the reservation is held.
443
444    /// Install the live session's outbound sink + this node's `peer_id` + the registered `network_id`.
445    /// Called by `connect_once` once registered; cleared by [`clear_transport`](Self::clear_transport)
446    /// on every drop.
447    fn set_transport(
448        &self,
449        peer_id: &str,
450        network_id: &str,
451        outbound: mpsc::UnboundedSender<RelayMessage>,
452    ) {
453        *self.local_peer_id.lock().unwrap() = Some(peer_id.to_string());
454        *self.local_network_id.lock().unwrap() = Some(network_id.to_string());
455        *self.outbound.lock().unwrap() = Some(outbound);
456    }
457
458    /// Enable the RESPONDER (accept) path and return the receiver of INTRODUCED inbound circuits.
459    ///
460    /// A relayed connection needs one mTLS client + one mTLS server. The dialer opens a tunnel and
461    /// runs the client; the reservation-HOLDER calls this once at startup and, for every inbound
462    /// frame from a peer it has no open outbound tunnel to, receives a server-role [`RelayTunnel`]
463    /// here to hand to a [`crate::accept::RelayAcceptor`] (which runs `PeerSession::server`). Until a
464    /// consumer calls this, unknown-peer frames are DROPPED (the untrusted-relay default), so the
465    /// accept path is strictly opt-in. The channel is bounded ([`INBOUND_ACCEPT_CAP`]).
466    pub fn enable_accept(&self) -> mpsc::Receiver<RelayTunnel> {
467        let (tx, rx) = mpsc::channel(INBOUND_ACCEPT_CAP);
468        *self.inbound_accept.lock().unwrap() = Some(tx);
469        rx
470    }
471
472    /// Tear down the transport on session drop: drop the outbound sink (so tunnel sends fail fast)
473    /// and close every open tunnel's inbound channel (so a blocked `recv` wakes with `None`).
474    fn clear_transport(&self) {
475        *self.outbound.lock().unwrap() = None;
476        self.tunnels.lock().unwrap().clear();
477    }
478
479    /// Whether a relayed tunnel can currently be opened — a reservation is held AND its outbound sink
480    /// is live. The tier-6 [`RelayedTransport`](crate::method::relayed::RelayedTransport) gates on this.
481    pub fn relay_transport_ready(&self) -> bool {
482        self.is_connected() && self.outbound.lock().unwrap().is_some()
483    }
484
485    /// Open an RLY-002 relayed-transport tunnel to `target_peer` (hex `peer_id`) over the held
486    /// reservation socket — the traversal ladder's FINAL tier when a pair can neither direct-dial nor
487    /// hole-punch. The returned [`RelayTunnel`] sends/receives opaque payloads that the relay forwards
488    /// A→relay→B; per NC-1 the payload is END-TO-END SEALED to the recipient so the relay forwards
489    /// ciphertext only. `Err` if no reservation is held. Dropping the tunnel deregisters it.
490    pub fn open_tunnel(
491        self: &Arc<Self>,
492        target_peer: &str,
493        network_id: &str,
494    ) -> Result<RelayTunnel, String> {
495        if !self.relay_transport_ready() {
496            return Err("relay reservation not connected — cannot open relayed tunnel".into());
497        }
498        // Self / SPKI-collision guard: a relayed circuit to our OWN peer_id has no lower/higher end
499        // for the glare tie-break, so it could never converge to one-client-one-server — refuse it
500        // (#1536).
501        let local = self
502            .local_peer_id
503            .lock()
504            .unwrap()
505            .clone()
506            .unwrap_or_default();
507        if !local.is_empty() && local == target_peer {
508            return Err("refusing relayed self-dial (target == local peer_id)".into());
509        }
510        // NON-CLOBBER (#1536): if a circuit to this peer already exists — because an introduced
511        // ClientHello made us its SERVER before this dial ran (a timing-ordered glare) — do NOT open a
512        // second, conflicting circuit under the same key. The existing circuit IS the connection; a
513        // duplicate would orphan one role and leave two mTLS sessions racing to one peer. Checked +
514        // inserted under ONE lock so a concurrent `route_relayed` cannot slip a role in between.
515        let mut tunnels = self.tunnels.lock().unwrap();
516        if tunnels.contains_key(target_peer) {
517            return Err("existing relay circuit to peer — not opening a duplicate".into());
518        }
519        // A dialer runs the mTLS client over the tunnel it opens.
520        Ok(self.insert_entry(&mut tunnels, target_peer, network_id, TunnelRole::Client))
521    }
522
523    /// Register a tunnel routing entry for `target_peer` and build its [`RelayTunnel`], overwriting any
524    /// existing entry under the key. Test-only (the `open_server_tunnel` + flood-cap tests use it); the
525    /// production paths ([`open_tunnel`](Self::open_tunnel) + [`accept_introduced`](Self::accept_introduced))
526    /// go through non-clobber checks first.
527    #[cfg(test)]
528    fn register_tunnel(
529        self: &Arc<Self>,
530        target_peer: &str,
531        network_id: &str,
532        role: TunnelRole,
533    ) -> RelayTunnel {
534        let mut tunnels = self.tunnels.lock().unwrap();
535        self.insert_entry(&mut tunnels, target_peer, network_id, role)
536    }
537
538    /// Build a fresh [`RelayTunnel`] for `target_peer` with `role` and insert its entry into the
539    /// already-locked `tunnels` map (assigning a monotonic id so a stale `Drop` never evicts a newer
540    /// registration). The caller holds the lock, so the check-then-insert is atomic — the #1536
541    /// non-clobber + role-race defense.
542    fn insert_entry(
543        self: &Arc<Self>,
544        tunnels: &mut HashMap<String, TunnelEntry>,
545        target_peer: &str,
546        network_id: &str,
547        role: TunnelRole,
548    ) -> RelayTunnel {
549        let (tx, rx) = mpsc::channel(RELAY_TUNNEL_INBOUND_CAP);
550        let id = self.next_tunnel_id.fetch_add(1, Ordering::Relaxed);
551        tunnels.insert(target_peer.to_string(), TunnelEntry { sink: tx, role, id });
552        RelayTunnel {
553            target: target_peer.to_string(),
554            network_id: network_id.to_string(),
555            status: Arc::clone(self),
556            inbound: rx,
557            id,
558        }
559    }
560
561    /// Route one inbound RLY-002 `relay_message` to its tunnel by `from` peer_id. Oversized payloads
562    /// are dropped (size cap); a frame from a peer with no open tunnel becomes an introduced circuit
563    /// (accepted as a server, or dropped when the responder path is off / a flood cap is hit); a full
564    /// inbound channel drops the frame (backpressure). Returns silently in every drop case (untrusted
565    /// relay).
566    ///
567    /// GLARE (#1536): when a ClientHello arrives on a tunnel where WE are ALSO the client — the peer
568    /// dialed us at the same time we dialed it — a deterministic tie-break makes exactly ONE side the
569    /// server: the numerically-LOWER `peer_id` becomes the server. Both ends compute the same rule, so
570    /// a crossed pair converges to one-client-one-server under ANY frame ordering with no retry loop.
571    /// The TIMING-ordered variant (a peer's ClientHello arrives BEFORE our own dial registers) cannot
572    /// produce a conflicting second circuit either: [`open_tunnel`](Self::open_tunnel) is non-clobber,
573    /// so once we serve a peer our later dial to it is refused. The per-frame role LOOKUP + any
574    /// same-frame yield (client-tunnel removal) happen under a single lock acquisition; the server
575    /// registration in [`accept_introduced`](Self::accept_introduced) re-acquires and re-checks
576    /// (non-clobber), so a dial racing between the two regions is abandoned, never a double-session.
577    fn route_relayed(self: &Arc<Self>, from: &str, payload: Vec<u8>) {
578        if payload.len() > MAX_RELAY_PAYLOAD {
579            tracing::debug!(
580                from,
581                len = payload.len(),
582                "dropping oversized relayed frame"
583            );
584            return;
585        }
586        let local = self
587            .local_peer_id
588            .lock()
589            .unwrap()
590            .clone()
591            .unwrap_or_default();
592        // Self / SPKI-collision guard: a frame stamped with our OWN id can never be a real remote peer
593        // (and the tie-break has no lower/higher end for it) — drop it rather than risk a no-server
594        // hang (#1536).
595        if !local.is_empty() && local == from {
596            tracing::debug!("dropping relayed frame stamped with our own peer_id (self/collision)");
597            return;
598        }
599
600        // Decide the action for this frame under ONE tunnels-lock so a concurrent `open_tunnel` cannot
601        // race the role assignment.
602        enum Route {
603            /// Deliver into an existing tunnel's inbound sink.
604            Deliver(mpsc::Sender<Vec<u8>>),
605            /// Drop the frame (we retain our client role, or cannot serve).
606            Ignore,
607            /// Accept as an introduced server-role circuit.
608            Accept,
609        }
610        let route = {
611            let mut tunnels = self.tunnels.lock().unwrap();
612            match tunnels.get(from).map(|e| (e.sink.clone(), e.role, e.id)) {
613                // We are the SERVER for this peer — every frame is the client's; route it.
614                Some((sink, TunnelRole::Server, _)) => Route::Deliver(sink),
615                // We are the CLIENT. A ServerHello / app record is the expected response; a ClientHello
616                // means the peer dialed us at the same time (GLARE).
617                Some((sink, TunnelRole::Client, id)) => {
618                    if !is_tls_client_hello(&payload) {
619                        Route::Deliver(sink)
620                    } else if local.as_str() > from {
621                        // Higher id → WE keep the client role; ignore the peer's competing ClientHello
622                        // (the lower-id peer yields to server and answers with a ServerHello).
623                        tracing::debug!(
624                            from,
625                            "relay glare — retaining client role (peer yields to server)"
626                        );
627                        Route::Ignore
628                    } else if self.inbound_accept.lock().unwrap().is_none() {
629                        // Lower id → should be server, but no responder path is enabled; cannot serve,
630                        // so keep the (doomed) client tunnel and drop rather than tear it down.
631                        tracing::debug!(
632                            from,
633                            "relay glare — should be server but accept path off; dropping"
634                        );
635                        Route::Ignore
636                    } else {
637                        // Lower id → yield to the server role: drop our client tunnel (only if it is
638                        // still ours) and accept the peer's circuit as server below.
639                        if tunnels.get(from).map(|e| e.id) == Some(id) {
640                            tunnels.remove(from);
641                        }
642                        tracing::debug!(from, "relay glare — yielding to server role");
643                        Route::Accept
644                    }
645                }
646                // No circuit yet — an INTRODUCED inbound circuit (a peer dialing us over the relay).
647                None => Route::Accept,
648            }
649        };
650
651        match route {
652            Route::Deliver(sink) => {
653                if sink.try_send(payload).is_err() {
654                    tracing::debug!(from, "relayed tunnel inbound full/closed — frame dropped");
655                }
656            }
657            Route::Ignore => {}
658            Route::Accept => self.accept_introduced(from, payload),
659        }
660    }
661
662    /// Accept an INTRODUCED inbound circuit from `from` as a server-role tunnel and surface it to the
663    /// consumer's [`RelayAcceptor`](crate::accept::RelayAcceptor) — the RESPONDER path. Gated on: the
664    /// responder path being enabled ([`enable_accept`](Self::enable_accept)), NON-CLOBBER (a circuit
665    /// already under this key is kept, never replaced by a racing registration — the #1536
666    /// double-session defense), and the flood cap ([`MAX_RELAY_TUNNELS`]). The opening frame (the
667    /// dialer's ClientHello) is delivered into the fresh tunnel so the server handshake sees it; a full
668    /// accept channel drops the newest circuit (bounded backpressure).
669    fn accept_introduced(self: &Arc<Self>, from: &str, payload: Vec<u8>) {
670        let Some(accept_tx) = self.inbound_accept.lock().unwrap().clone() else {
671            return;
672        };
673        let network_id = self
674            .local_network_id
675            .lock()
676            .unwrap()
677            .clone()
678            .unwrap_or_default();
679        let tunnel = {
680            let mut tunnels = self.tunnels.lock().unwrap();
681            if tunnels.contains_key(from) {
682                // A circuit to this peer already exists (a racing dial claimed the key) — do NOT
683                // clobber it into a conflicting second session.
684                return;
685            }
686            if tunnels.len() >= MAX_RELAY_TUNNELS {
687                tracing::debug!(
688                    from,
689                    "inbound relay accept cap reached — dropping introduced circuit"
690                );
691                return;
692            }
693            self.insert_entry(&mut tunnels, from, &network_id, TunnelRole::Server)
694        };
695        // Deliver the opening frame so the server-side handshake sees the ClientHello.
696        if let Some(sink) = self
697            .tunnels
698            .lock()
699            .unwrap()
700            .get(from)
701            .map(|e| e.sink.clone())
702        {
703            let _ = sink.try_send(payload);
704        }
705        // Hand the server-role tunnel to the consumer to run `PeerSession::server` over. A full/closed
706        // accept channel drops the tunnel here — its `Drop` deregisters the routing.
707        if accept_tx.try_send(tunnel).is_err() {
708            tracing::debug!(
709                from,
710                "inbound accept channel full/closed — dropping introduced circuit"
711            );
712        }
713    }
714
715    /// Remove a tunnel's routing entry (called on [`RelayTunnel`] drop) — but ONLY when the stored
716    /// entry is still THIS registration (`id` matches). A glare tie-break can replace our client
717    /// tunnel with a server tunnel under the same peer key (#1536); the old client tunnel's `Drop`
718    /// must not then evict the newer server entry.
719    fn close_tunnel(&self, target_peer: &str, id: u64) {
720        let mut tunnels = self.tunnels.lock().unwrap();
721        if tunnels.get(target_peer).map(|e| e.id) == Some(id) {
722            tunnels.remove(target_peer);
723        }
724    }
725
726    /// Whether a relayed tunnel to `target_peer` is currently registered — the test hook fast-connect
727    /// uses to assert the per-peer tunnel was released (dropped) after a relayed→direct promotion,
728    /// while the reservation itself stays held.
729    #[cfg(test)]
730    pub(crate) fn open_tunnel_exists(&self, target_peer: &str) -> bool {
731        self.tunnels.lock().unwrap().contains_key(target_peer)
732    }
733
734    /// Test-only: register a SERVER-role tunnel to `target_peer` directly, for tests that drive an mTLS
735    /// SERVER over a hand-wired relay tunnel (the production server path is [`enable_accept`] +
736    /// [`route_relayed`]'s accept branch). A server-role tunnel routes an incoming ClientHello straight
737    /// through instead of treating it as the #1536 glare signal, so these tests mirror a real server
738    /// receiving a dialer's ClientHello.
739    #[cfg(test)]
740    pub(crate) fn open_server_tunnel(
741        self: &Arc<Self>,
742        target_peer: &str,
743        network_id: &str,
744    ) -> RelayTunnel {
745        self.register_tunnel(target_peer, network_id, TunnelRole::Server)
746    }
747
748    /// A JSON snapshot for a `control.relayStatus`-style RPC. `state` is the canonical truth;
749    /// `connected` is a convenience boolean (== `state == connected`).
750    pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
751        let state = self.state();
752        serde_json::json!({
753            "state": state.as_str(),
754            "connected": state == RelayState::Connected,
755            "endpoint": endpoint,
756            "peer_id": peer_id,
757            "reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
758            "connected_peers": self.connected_peers.load(Ordering::Relaxed),
759            "last_error": *self.last_error.lock().unwrap(),
760        })
761    }
762}
763
764/// A live RLY-002 relayed-transport tunnel to one peer, multiplexed over the node's persistent relay
765/// reservation socket (the tier-6 TURN fallback). Writes are framed as RLY-002 `relay_message` to the
766/// target and forwarded A→relay→B; reads are the payloads the relay forwards back from that peer.
767///
768/// Per NC-1 the payload MUST be END-TO-END SEALED to the recipient's key by the caller — the relay is
769/// an untrusted forwarder that sees only ciphertext. Dropping the tunnel deregisters its routing.
770pub struct RelayTunnel {
771    /// The remote peer's `peer_id` (hex) — the RLY-002 `to`, and the routing key for inbound frames.
772    target: String,
773    /// The network the tunnel is scoped to (echoed for the consumer; relay routes by peer_id).
774    network_id: String,
775    /// Shared status handle — provides the live outbound sink, this node's `peer_id`, and the seq.
776    status: Arc<RelayStatus>,
777    /// Inbound payloads the relay forwarded from `target`, in arrival order (bounded — see
778    /// [`RELAY_TUNNEL_INBOUND_CAP`]).
779    inbound: mpsc::Receiver<Vec<u8>>,
780    /// This registration's monotonic id — so `Drop` only deregisters when the map still holds THIS
781    /// entry (a glare replace may have superseded it under the same key; #1536).
782    id: u64,
783}
784
785impl RelayTunnel {
786    /// The remote peer this tunnel forwards to/from (hex `peer_id`).
787    pub fn target(&self) -> &str {
788        &self.target
789    }
790
791    /// The network the tunnel is scoped to.
792    pub fn network_id(&self) -> &str {
793        &self.network_id
794    }
795
796    /// Send `payload` to the target peer through the relay (RLY-002 `relay_message`). `payload` MUST
797    /// already be sealed to the recipient (NC-1). `Err` if the reservation dropped (send after the
798    /// session closed) or `payload` exceeds [`MAX_RELAY_PAYLOAD`].
799    pub fn send(&self, payload: Vec<u8>) -> Result<(), String> {
800        if payload.len() > MAX_RELAY_PAYLOAD {
801            return Err(format!(
802                "relayed payload {} exceeds cap {MAX_RELAY_PAYLOAD}",
803                payload.len()
804            ));
805        }
806        let from = self
807            .status
808            .local_peer_id
809            .lock()
810            .unwrap()
811            .clone()
812            .ok_or("relay reservation not connected — no local peer_id")?;
813        let seq = self.status.relay_seq.fetch_add(1, Ordering::Relaxed);
814        let frame = RelayMessage::RelayGossipMessage {
815            from,
816            to: self.target.clone(),
817            payload,
818            seq,
819        };
820        let guard = self.status.outbound.lock().unwrap();
821        let sink = guard
822            .as_ref()
823            .ok_or("relay reservation not connected — cannot send relayed frame")?;
824        sink.send(frame)
825            .map_err(|_| "relay reservation write half closed".to_string())
826    }
827
828    /// Await the next payload the relay forwards from the target peer. `None` once the reservation
829    /// drops (the session closed) — the caller should re-open the tunnel after the relay reconnects.
830    pub async fn recv(&mut self) -> Option<Vec<u8>> {
831        self.inbound.recv().await
832    }
833
834    /// Poll for the next inbound payload. This is the non-`async` primitive the
835    /// [`RelayTunnelStream`](crate::tunnel::RelayTunnelStream) `AsyncRead` adapter drives so an mTLS
836    /// session can run OVER the relay tunnel. `Poll::Ready(None)` once the reservation drops.
837    pub(crate) fn poll_recv(
838        &mut self,
839        cx: &mut std::task::Context<'_>,
840    ) -> std::task::Poll<Option<Vec<u8>>> {
841        self.inbound.poll_recv(cx)
842    }
843}
844
845impl Drop for RelayTunnel {
846    fn drop(&mut self) {
847        self.status.close_tunnel(&self.target, self.id);
848    }
849}
850
851/// Resolve the relay endpoint: `DIG_RELAY_URL` if set + non-empty (and not the opt-out token), else
852/// the canonical [`dig_constants::DIG_RELAY_URL`].
853pub fn relay_url_from_env() -> String {
854    std::env::var("DIG_RELAY_URL")
855        .ok()
856        .filter(|s| !s.trim().is_empty())
857        .filter(|s| !is_off_token(s))
858        .unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
859}
860
861/// Whether the relay connection is enabled. Disabled when `DIG_RELAY_URL` is `off`/`disabled`/
862/// empty-after-trim — an explicit opt-out for air-gapped/standalone nodes.
863pub fn relay_enabled() -> bool {
864    match std::env::var("DIG_RELAY_URL") {
865        Ok(v) => !is_off_token(&v),
866        Err(_) => true,
867    }
868}
869
870/// `true` if `v` is the reservation opt-out token (`off`/`disabled`, case-insensitive, trimmed).
871fn is_off_token(v: &str) -> bool {
872    let v = v.trim();
873    v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
874}
875
876/// Current unix time (seconds), saturating.
877fn now_secs() -> u64 {
878    std::time::SystemTime::now()
879        .duration_since(std::time::UNIX_EPOCH)
880        .map(|d| d.as_secs())
881        .unwrap_or(0)
882}
883
884/// Maintain a CONSTANT relay reservation forever: connect, register, keepalive, and on any drop
885/// reconnect with capped exponential backoff. Spawned as a background task; tolerates the relay
886/// being down (retries forever, never crashes). `peer_id` is the node's stable identity hex.
887pub async fn run_relay_connection(
888    endpoint: String,
889    peer_id: String,
890    network_id: String,
891    listen_addrs: Vec<SocketAddr>,
892    status: Arc<RelayStatus>,
893) {
894    run_relay_connection_with(
895        endpoint,
896        peer_id,
897        network_id,
898        listen_addrs,
899        status,
900        Backoff::default(),
901    )
902    .await
903}
904
905/// [`run_relay_connection`] with an explicit backoff schedule (tests pass tiny values for fast,
906/// deterministic reconnect timing; the LOGIC is identical — only the sleep durations differ).
907pub async fn run_relay_connection_with(
908    endpoint: String,
909    peer_id: String,
910    network_id: String,
911    listen_addrs: Vec<SocketAddr>,
912    status: Arc<RelayStatus>,
913    backoff: Backoff,
914) {
915    let mut consecutive_failures: u32 = 0;
916    loop {
917        status.set_connecting();
918        match connect_once(&endpoint, &peer_id, &network_id, &listen_addrs, &status).await {
919            Ok(()) => {
920                consecutive_failures = 0;
921                status.set_disconnected(None);
922            }
923            Err(e) => {
924                consecutive_failures = consecutive_failures.saturating_add(1);
925                status.set_disconnected(Some(e));
926            }
927        }
928        // ALWAYS sleep a bounded backoff before retrying — prevents a busy error loop.
929        let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
930        tokio::time::sleep(Duration::from_secs(delay)).await;
931    }
932}
933
934/// A relay WebSocket endpoint parsed into the pieces the happy-eyeballs dial needs: the host to
935/// resolve and the TCP port. The scheme (`ws`/`wss`) only selects the default port here — the
936/// plaintext-vs-TLS choice is re-derived from the URL by [`client_async_tls_with_config`] during the
937/// handshake, so a single code path serves both.
938#[derive(Debug, PartialEq, Eq)]
939struct RelayEndpoint {
940    host: String,
941    port: u16,
942}
943
944/// Parse a relay endpoint URL (`ws://host[:port][/path]` / `wss://host[:port][/path]`, IPv6 hosts in
945/// `[…]`) into its host + port. Only the authority is needed for the dial; any path/query/fragment and
946/// userinfo are ignored (the full URL is still handed to the WS handshake for the correct `Host`/SNI).
947fn parse_relay_endpoint(endpoint: &str) -> Result<RelayEndpoint, String> {
948    let (scheme, rest) = endpoint
949        .split_once("://")
950        .ok_or_else(|| format!("relay endpoint missing scheme: {endpoint}"))?;
951    let default_port = match scheme.to_ascii_lowercase().as_str() {
952        "ws" => 80,
953        "wss" => 443,
954        other => return Err(format!("unsupported relay scheme: {other}")),
955    };
956    // Authority only: drop any path/query/fragment, then any `userinfo@`.
957    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
958    let authority = authority
959        .rsplit_once('@')
960        .map(|(_, h)| h)
961        .unwrap_or(authority);
962
963    let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
964        // Bracketed IPv6 literal: `[addr]` or `[addr]:port`.
965        let (h, after) = stripped
966            .split_once(']')
967            .ok_or_else(|| format!("malformed IPv6 authority: {authority}"))?;
968        let port = match after.strip_prefix(':') {
969            Some(p) => p.parse().map_err(|_| format!("bad relay port: {after}"))?,
970            None => default_port,
971        };
972        (h.to_string(), port)
973    } else if let Some((h, p)) = authority.rsplit_once(':') {
974        (
975            h.to_string(),
976            p.parse().map_err(|_| format!("bad relay port: {p}"))?,
977        )
978    } else {
979        (authority.to_string(), default_port)
980    };
981
982    if host.is_empty() {
983        return Err(format!("relay endpoint missing host: {endpoint}"));
984    }
985    Ok(RelayEndpoint { host, port })
986}
987
988/// Resolve a relay host to its family-tagged dial candidates: a literal IP yields one candidate (no
989/// DNS), a hostname is resolved to its full A + AAAA set. The candidates feed `dig_ip::connect`, which
990/// applies the §5.2 IPv6-first preference + local∩peer family intersection, so no ordering is imposed
991/// here — the addresses are added as resolved and tagged by family for observability.
992async fn resolve_relay_candidates(host: &str, port: u16) -> Result<PeerCandidates, String> {
993    let mut candidates = PeerCandidates::new();
994    let source_for = |ip: &IpAddr| {
995        if ip.is_ipv6() {
996            CandidateSource::DnsAAAA
997        } else {
998            CandidateSource::DnsA
999        }
1000    };
1001    if let Ok(ip) = host.parse::<IpAddr>() {
1002        candidates.add(SocketAddr::new(ip, port), source_for(&ip));
1003    } else {
1004        let resolved = tokio::net::lookup_host((host, port))
1005            .await
1006            .map_err(|e| format!("resolve {host}:{port}: {e}"))?;
1007        for addr in resolved {
1008            candidates.add(addr, source_for(&addr.ip()));
1009        }
1010    }
1011    if candidates.is_empty() {
1012        return Err(format!("no addresses resolved for {host}:{port}"));
1013    }
1014    Ok(candidates)
1015}
1016
1017/// Race the relay `candidates` IPv6-first with graceful IPv4 fallback via `dig_ip::connect` (§5.2,
1018/// RFC 8305). The transport connect stays a caller-supplied closure so the racing logic is unit-tested
1019/// with a fake dial (no real DNS/sockets) exactly as the direct-peer dialer does in `dialer.rs`; the
1020/// production caller ([`open_relay_ws`]) hands it a real [`TcpStream::connect`].
1021async fn race_relay_candidates<C, F, Fut>(
1022    local: &LocalStack,
1023    candidates: &PeerCandidates,
1024    config: DialConfig,
1025    dial_fn: F,
1026) -> Result<dig_ip::DialWinner<C>, String>
1027where
1028    F: Fn(SocketAddr) -> Fut + Sync,
1029    Fut: std::future::Future<Output = Result<C, String>> + Send,
1030    C: Send,
1031{
1032    dig_ip::connect(local, candidates, config, dial_fn)
1033        .await
1034        .map_err(|e| format!("relay happy-eyeballs dial: {e}"))
1035}
1036
1037/// Open the relay WebSocket over an IPv6-first happy-eyeballs TCP race (§5.2), matching the direct-peer
1038/// dial path in `dialer.rs`: resolve the endpoint host to its A + AAAA candidates, race the TCP connect
1039/// via `dig_ip::connect` (IPv6-first, fast IPv4 fallback), then run the WS handshake over the WINNING
1040/// socket — TLS-over-that-stream for `wss://`, plaintext for `ws://` (the mode is taken from the URL by
1041/// [`client_async_tls_with_config`]). Replaces `tokio_tungstenite::connect_async`, whose sequential,
1042/// single-family resolve-and-connect contradicted the IPv6-first reservation guarantee.
1043async fn open_relay_ws(
1044    endpoint: &str,
1045) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, String> {
1046    let parsed = parse_relay_endpoint(endpoint)?;
1047    let candidates = resolve_relay_candidates(&parsed.host, parsed.port).await?;
1048    let local = LocalStack::cached();
1049    let winner = race_relay_candidates(
1050        &local,
1051        &candidates,
1052        DialConfig::default(),
1053        |addr| async move {
1054            TcpStream::connect(addr)
1055                .await
1056                .map_err(|e| format!("tcp connect {addr}: {e}"))
1057        },
1058    )
1059    .await?;
1060    let (ws, _resp) = client_async_tls_with_config(endpoint, winner.conn, None, None)
1061        .await
1062        .map_err(|e| format!("ws handshake: {e}"))?;
1063    Ok(ws)
1064}
1065
1066/// One connect → register → serve cycle. Returns `Ok` on a clean close, `Err(reason)` on failure.
1067async fn connect_once(
1068    endpoint: &str,
1069    peer_id: &str,
1070    network_id: &str,
1071    listen_addrs: &[SocketAddr],
1072    status: &Arc<RelayStatus>,
1073) -> Result<(), String> {
1074    // Each session's discovered-peer set + transport are independent — never carry state across a
1075    // drop. `clear_transport` also runs at the end so a dropped session's tunnels/sink never linger.
1076    status.clear_known_peers();
1077    status.clear_transport();
1078
1079    let ws = open_relay_ws(endpoint).await?;
1080    let (mut write, mut read) = ws.split();
1081
1082    // RLY-001: register immediately so the relay holds our reservation, advertising the node's gossip
1083    // listen candidates (B1) so the relay can hand other peers a dialable candidate (§5.2 IPv6-first).
1084    let register = RelayMessage::Register {
1085        peer_id: peer_id.to_string(),
1086        network_id: network_id.to_string(),
1087        protocol_version: RELAY_PROTOCOL_VERSION,
1088        listen_addrs: listen_addrs.to_vec(),
1089    };
1090    send(&mut write, &register).await?;
1091
1092    // Publish the outbound sink so RLY-002 relayed tunnels can reuse THIS persistent socket. Drained
1093    // in the select loop below; cleared when the session ends.
1094    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayMessage>();
1095    status.set_transport(peer_id, network_id, out_tx);
1096
1097    // RLY-005: pull the current peer list right away, then again periodically — all over THIS
1098    // persistent socket, so discovery never requires reopening a connection.
1099    let get_peers = RelayMessage::GetPeers {
1100        network_id: Some(network_id.to_string()),
1101    };
1102    send(&mut write, &get_peers).await?;
1103
1104    let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
1105    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1106    ping.tick().await; // skip the immediate first tick
1107
1108    let mut discovery = tokio::time::interval(Duration::from_secs(DISCOVERY_INTERVAL_SECS));
1109    discovery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1110    discovery.tick().await; // skip the immediate first tick (we already pulled once above)
1111
1112    // Run the session; whatever the outcome, tear the transport down so a dropped session never
1113    // leaves a stale outbound sink or open tunnels behind (they'd send into a closed socket).
1114    let result = serve_session(
1115        &mut write,
1116        &mut read,
1117        &mut ping,
1118        &mut discovery,
1119        &mut out_rx,
1120        network_id,
1121        status,
1122    )
1123    .await;
1124    status.clear_transport();
1125    result
1126}
1127
1128/// The connected-session select loop: keepalive pings, periodic RLY-005 discovery, draining the
1129/// outbound relayed-transport sink onto the socket, and handling inbound frames. Returns `Ok` on a
1130/// clean close, `Err(reason)` on a failure. Split out of `connect_once` so its caller can always run
1131/// transport teardown regardless of how the session ends.
1132#[allow(clippy::too_many_arguments)]
1133async fn serve_session<W, R>(
1134    write: &mut W,
1135    read: &mut R,
1136    ping: &mut tokio::time::Interval,
1137    discovery: &mut tokio::time::Interval,
1138    out_rx: &mut mpsc::UnboundedReceiver<RelayMessage>,
1139    network_id: &str,
1140    status: &Arc<RelayStatus>,
1141) -> Result<(), String>
1142where
1143    W: SinkExt<Message> + Unpin,
1144    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1145    R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
1146{
1147    loop {
1148        tokio::select! {
1149            _ = ping.tick() => {
1150                send(write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
1151            }
1152            _ = discovery.tick() => {
1153                send(write, &RelayMessage::GetPeers {
1154                    network_id: Some(network_id.to_string()),
1155                }).await?;
1156            }
1157            // A relayed tunnel queued an RLY-002 frame — forward it over THIS persistent socket.
1158            Some(frame) = out_rx.recv() => {
1159                send(write, &frame).await?;
1160            }
1161            frame = read.next() => {
1162                match frame {
1163                    None => return Ok(()),
1164                    Some(Err(e)) => return Err(format!("read: {e}")),
1165                    Some(Ok(Message::Close(_))) => return Ok(()),
1166                    Some(Ok(Message::Ping(p))) => {
1167                        write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
1168                    }
1169                    Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
1170                    Some(Ok(Message::Text(t))) => {
1171                        handle_incoming(t.into_bytes(), write, status).await?;
1172                    }
1173                    Some(Ok(Message::Binary(b))) => {
1174                        handle_incoming(b, write, status).await?;
1175                    }
1176                }
1177            }
1178        }
1179    }
1180}
1181
1182/// Handle one decoded inbound relay frame: track RegisterAck (→ connected), answer relay Pings.
1183async fn handle_incoming<W>(
1184    bytes: Vec<u8>,
1185    write: &mut W,
1186    status: &Arc<RelayStatus>,
1187) -> Result<(), String>
1188where
1189    W: SinkExt<Message> + Unpin,
1190    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1191{
1192    let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
1193        return Ok(()); // ignore anything we can't parse; the relay is untrusted
1194    };
1195    match msg {
1196        RelayMessage::RegisterAck {
1197            success,
1198            message,
1199            connected_peers,
1200        } => {
1201            if success {
1202                status.set_connected(connected_peers as u64);
1203            } else {
1204                return Err(format!("register rejected: {message}"));
1205            }
1206        }
1207        RelayMessage::Ping { timestamp } => {
1208            send(write, &RelayMessage::Pong { timestamp }).await?;
1209        }
1210        // RLY-005 + push notices: fold peers discovered over the live socket into the status so the
1211        // consumer's pool/address book sees them without opening an ephemeral discovery connection.
1212        RelayMessage::Peers { peers } => status.replace_known_peers(peers),
1213        RelayMessage::PeerConnected { peer } => status.add_known_peer(peer),
1214        RelayMessage::PeerDisconnected { peer_id } => status.remove_known_peer(&peer_id),
1215        // RLY-002 relayed transport (tier-6 TURN): route a payload the relay forwarded from `from` to
1216        // that peer's open tunnel. Unknown-peer / oversized / full-channel frames are dropped inside
1217        // `route_relayed` (untrusted-relay defense). `to`/`seq` are the relay's concern; we key on
1218        // `from`. Per NC-1 `payload` is sealed ciphertext the relay could not read.
1219        RelayMessage::RelayGossipMessage { from, payload, .. } => {
1220            status.route_relayed(&from, payload)
1221        }
1222        RelayMessage::Error { code, message } => {
1223            return Err(format!("relay error {code}: {message}"));
1224        }
1225        other => tracing::debug!(?other, "relay message ignored by reservation loop"),
1226    }
1227    Ok(())
1228}
1229
1230/// Wire two in-memory relay reservations to forward RLY-002 frames to each OTHER — a loopback relay
1231/// with no real network. Each returned [`RelayStatus`] is `Connected` with a live outbound sink whose
1232/// frames are routed into the peer's tunnels by `from` peer_id, exactly as a real relay would forward
1233/// A→relay→B. Used to prove a full mTLS session round-trips over [`RelayTunnel`]s (see `tunnel.rs`).
1234///
1235/// `a` opens tunnels targeting `b_id`; `b` opens tunnels targeting `a_id`.
1236#[cfg(test)]
1237pub(crate) fn loopback_reservation_pair(
1238    a_id: &str,
1239    b_id: &str,
1240) -> (Arc<RelayStatus>, Arc<RelayStatus>) {
1241    let a = RelayStatus::new();
1242    let b = RelayStatus::new();
1243    a.set_connected(1);
1244    b.set_connected(1);
1245
1246    let (a_tx, mut a_rx) = mpsc::unbounded_channel::<RelayMessage>();
1247    let (b_tx, mut b_rx) = mpsc::unbounded_channel::<RelayMessage>();
1248    a.set_transport(a_id, DEFAULT_NETWORK_ID, a_tx);
1249    b.set_transport(b_id, DEFAULT_NETWORK_ID, b_tx);
1250
1251    // Drain a's outbound → forward into b (route by `from`), and symmetrically b → a. This is the
1252    // relay's forwarding role, in-process.
1253    let b_route = Arc::clone(&b);
1254    tokio::spawn(async move {
1255        while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = a_rx.recv().await {
1256            b_route.route_relayed(&from, payload);
1257        }
1258    });
1259    let a_route = Arc::clone(&a);
1260    tokio::spawn(async move {
1261        while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = b_rx.recv().await {
1262            a_route.route_relayed(&from, payload);
1263        }
1264    });
1265
1266    (a, b)
1267}
1268
1269/// Serialize + send one `RelayMessage` as a WebSocket text frame.
1270async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
1271where
1272    W: SinkExt<Message> + Unpin,
1273    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1274{
1275    let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
1276    write
1277        .send(Message::Text(txt))
1278        .await
1279        .map_err(|e| format!("send: {e}"))
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284    use super::*;
1285    use dig_ip::Family;
1286    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
1287    use std::sync::Mutex as StdMutex;
1288
1289    #[test]
1290    fn parses_wss_host_and_explicit_port() {
1291        let ep = parse_relay_endpoint("wss://relay.dig.net:443").unwrap();
1292        assert_eq!(ep.host, "relay.dig.net");
1293        assert_eq!(ep.port, 443);
1294    }
1295
1296    #[test]
1297    fn parses_default_ports_by_scheme() {
1298        assert_eq!(
1299            parse_relay_endpoint("wss://relay.dig.net").unwrap().port,
1300            443
1301        );
1302        assert_eq!(parse_relay_endpoint("ws://relay.dig.net").unwrap().port, 80);
1303    }
1304
1305    #[test]
1306    fn parses_bracketed_ipv6_authority_with_and_without_port() {
1307        let with_port = parse_relay_endpoint("wss://[2001:db8::1]:8443").unwrap();
1308        assert_eq!(with_port.host, "2001:db8::1");
1309        assert_eq!(with_port.port, 8443);
1310        let no_port = parse_relay_endpoint("wss://[2001:db8::1]/ws").unwrap();
1311        assert_eq!(no_port.host, "2001:db8::1");
1312        assert_eq!(no_port.port, 443);
1313    }
1314
1315    #[test]
1316    fn ignores_path_query_and_userinfo() {
1317        let ep = parse_relay_endpoint("wss://user@relay.dig.net:9443/ws?x=1#f").unwrap();
1318        assert_eq!(ep.host, "relay.dig.net");
1319        assert_eq!(ep.port, 9443);
1320    }
1321
1322    #[test]
1323    fn rejects_malformed_endpoints() {
1324        assert!(parse_relay_endpoint("relay.dig.net:443").is_err()); // no scheme
1325        assert!(parse_relay_endpoint("http://relay.dig.net").is_err()); // wrong scheme
1326        assert!(parse_relay_endpoint("wss://relay.dig.net:notaport").is_err());
1327    }
1328
1329    #[tokio::test]
1330    async fn resolve_relay_candidates_handles_ip_literals_without_dns() {
1331        let v6 = resolve_relay_candidates("2001:db8::1", 443).await.unwrap();
1332        assert_eq!(v6.all().len(), 1);
1333        assert_eq!(v6.all()[0].family, Family::V6);
1334        assert_eq!(v6.all()[0].source, CandidateSource::DnsAAAA);
1335
1336        let v4 = resolve_relay_candidates("203.0.113.7", 443).await.unwrap();
1337        assert_eq!(v4.all()[0].family, Family::V4);
1338        assert_eq!(v4.all()[0].source, CandidateSource::DnsA);
1339    }
1340
1341    /// The relay dial races BOTH families and falls back to IPv4 when the IPv6 candidate is dead —
1342    /// the §5.2 happy-eyeballs guarantee, proven with a FAKE dial closure (no real DNS/sockets). A
1343    /// dead IPv6 candidate + a live IPv4 candidate on a dual-stack host must yield the IPv4 winner,
1344    /// and BOTH families must have been attempted.
1345    #[tokio::test]
1346    async fn relay_dial_races_both_families_and_falls_back_to_ipv4() {
1347        let mut candidates = PeerCandidates::new();
1348        let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1349        let v4: SocketAddr = "203.0.113.7:443".parse().unwrap();
1350        candidates.add(v6, CandidateSource::DnsAAAA);
1351        candidates.add(v4, CandidateSource::DnsA);
1352
1353        let dual = LocalStack::from_flags(true, true);
1354        let attempted: StdMutex<Vec<SocketAddr>> = StdMutex::new(Vec::new());
1355        // Fast attempt-delay so the hedged IPv4 starts promptly once the IPv6 attempt fails.
1356        let cfg = DialConfig {
1357            per_attempt_timeout: Duration::from_secs(1),
1358            attempt_delay: Duration::from_millis(5),
1359        };
1360
1361        let winner = race_relay_candidates(&dual, &candidates, cfg, |addr| {
1362            let attempted = &attempted;
1363            async move {
1364                attempted.lock().unwrap().push(addr);
1365                if addr.is_ipv6() {
1366                    Err(format!("simulated dead IPv6 {addr}"))
1367                } else {
1368                    Ok(addr) // the fake "connection" is just the address that won
1369                }
1370            }
1371        })
1372        .await
1373        .expect("IPv4 fallback wins when IPv6 is dead");
1374
1375        assert_eq!(winner.conn, v4, "the live IPv4 candidate won");
1376        assert_eq!(winner.family, Family::V4);
1377        let tried = attempted.lock().unwrap();
1378        assert!(
1379            tried.contains(&v6),
1380            "the IPv6 candidate was attempted first"
1381        );
1382        assert!(
1383            tried.contains(&v4),
1384            "the IPv4 candidate was attempted as fallback"
1385        );
1386    }
1387
1388    /// IPv6 is the PREFERENCE, not merely first-attempted: with both families live on a dual-stack
1389    /// host, the IPv6 candidate wins the race (IPv4 is only a fallback).
1390    #[tokio::test]
1391    async fn relay_dial_prefers_ipv6_when_both_live() {
1392        let mut candidates = PeerCandidates::new();
1393        let v6: SocketAddr = "[2001:db8::2]:443".parse().unwrap();
1394        let v4: SocketAddr = "203.0.113.8:443".parse().unwrap();
1395        candidates.add(v6, CandidateSource::DnsAAAA);
1396        candidates.add(v4, CandidateSource::DnsA);
1397
1398        let dual = LocalStack::from_flags(true, true);
1399        let calls = AtomicUsize::new(0);
1400        let winner = race_relay_candidates(&dual, &candidates, DialConfig::default(), |addr| {
1401            calls.fetch_add(1, AtomicOrdering::Relaxed);
1402            async move { Ok::<SocketAddr, String>(addr) }
1403        })
1404        .await
1405        .unwrap();
1406
1407        assert_eq!(winner.conn, v6, "IPv6 preferred when both are viable");
1408        assert_eq!(winner.family, Family::V6);
1409    }
1410
1411    /// Build a `Connected` status with a live (dummy) outbound sink + local identity, so
1412    /// `route_relayed`'s introduced-circuit path can run without a real relay socket.
1413    fn connected_status(local_id: &str) -> Arc<RelayStatus> {
1414        let status = RelayStatus::new();
1415        status.set_connected(1);
1416        let (out_tx, _out_rx) = mpsc::unbounded_channel::<RelayMessage>();
1417        status.set_transport(local_id, DEFAULT_NETWORK_ID, out_tx);
1418        // These tests exercise the introduced-circuit ROUTING (register/accept/drop), which never uses
1419        // the outbound sink, so the receiver may drop at end of scope — the sink stays `Some`.
1420        status
1421    }
1422
1423    /// SECURITY (accept OFF): with NO responder path enabled, an introduced RLY-002 frame from an
1424    /// unknown peer is DROPPED — no tunnel is created and nothing is surfaced. This is the
1425    /// untrusted-relay default: a node that never opted into accepting relayed circuits cannot be made
1426    /// to spawn one by a hostile relay.
1427    #[test]
1428    fn introduced_frame_dropped_when_accept_disabled() {
1429        let status = connected_status("00aa");
1430        // A ClientHello-shaped frame from a peer we hold NO tunnel to — the introduced-circuit trigger.
1431        status.route_relayed("ffbb", vec![0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0, 0, 0, 0]);
1432        assert!(
1433            !status.open_tunnel_exists("ffbb"),
1434            "no tunnel surfaced for an introduced circuit while accept is off"
1435        );
1436        assert_eq!(
1437            status.tunnels.lock().unwrap().len(),
1438            0,
1439            "accept-off drops the introduced circuit entirely"
1440        );
1441    }
1442
1443    /// SECURITY (flood defense, tunnel cap): once [`MAX_RELAY_TUNNELS`] tunnels are open, a further
1444    /// introduced circuit from a new peer is DROPPED rather than registered — a hostile relay flooding
1445    /// distinct fabricated `from` ids cannot spawn unbounded server tunnels/accept-tasks.
1446    #[test]
1447    fn introduced_circuit_dropped_at_max_tunnels_cap() {
1448        let status = connected_status("00aa");
1449        let mut accept_rx = status.enable_accept();
1450        // Saturate the tunnel table at the cap (held open by the returned RelayTunnels).
1451        let mut held = Vec::new();
1452        for i in 0..MAX_RELAY_TUNNELS {
1453            held.push(status.register_tunnel(
1454                &format!("peer{i:05}"),
1455                DEFAULT_NETWORK_ID,
1456                TunnelRole::Server,
1457            ));
1458        }
1459        assert_eq!(status.tunnels.lock().unwrap().len(), MAX_RELAY_TUNNELS);
1460
1461        status.route_relayed("overflowpeer", vec![1, 2, 3]);
1462        assert!(
1463            !status.open_tunnel_exists("overflowpeer"),
1464            "an introduced circuit beyond the tunnel cap is dropped, not registered"
1465        );
1466        assert_eq!(
1467            status.tunnels.lock().unwrap().len(),
1468            MAX_RELAY_TUNNELS,
1469            "tunnel count never grows past the cap"
1470        );
1471        assert!(
1472            accept_rx.try_recv().is_err(),
1473            "the capped circuit is never surfaced to the acceptor"
1474        );
1475        drop(held);
1476    }
1477
1478    /// SECURITY (flood defense, accept channel): when the bounded inbound-accept channel
1479    /// ([`INBOUND_ACCEPT_CAP`]) is full — the consumer is not accepting fast enough — a further
1480    /// introduced circuit is DROPPED (its freshly-registered tunnel is torn down), bounded
1481    /// backpressure rather than unbounded queueing.
1482    #[test]
1483    fn introduced_circuit_dropped_when_accept_channel_full() {
1484        let status = connected_status("00aa");
1485        let mut accept_rx = status.enable_accept();
1486        // Fill the accept channel to capacity WITHOUT draining it — each surfaced circuit occupies one
1487        // slot and keeps its server tunnel registered (the RelayTunnel lives in the channel).
1488        for i in 0..INBOUND_ACCEPT_CAP {
1489            status.route_relayed(&format!("in{i:05}"), vec![9, 9, 9]);
1490        }
1491        assert_eq!(
1492            status.tunnels.lock().unwrap().len(),
1493            INBOUND_ACCEPT_CAP,
1494            "each surfaced circuit registered exactly one server tunnel"
1495        );
1496
1497        // One more: the accept channel is full → the tunnel is registered then immediately dropped,
1498        // so its routing is deregistered and nothing new is surfaced.
1499        status.route_relayed("overflow", vec![9, 9, 9]);
1500        assert!(
1501            !status.open_tunnel_exists("overflow"),
1502            "an introduced circuit is dropped when the accept channel is full"
1503        );
1504
1505        let mut surfaced = 0;
1506        while accept_rx.try_recv().is_ok() {
1507            surfaced += 1;
1508        }
1509        assert_eq!(
1510            surfaced, INBOUND_ACCEPT_CAP,
1511            "exactly the channel capacity surfaced — never the overflow circuit"
1512        );
1513    }
1514
1515    /// REGRESSION (#1536 glare, TIMING order): a peer's ClientHello arrives BEFORE our own dial to it
1516    /// registers. We accept it as a server; our later dial to the SAME peer MUST then be refused
1517    /// (non-clobber) so no conflicting second circuit / double mTLS session is created. This is the
1518    /// deeper ordering the first tie-break missed (role was decided by who-registered-first).
1519    #[test]
1520    fn clienthello_before_local_dial_does_not_double_register() {
1521        let status = connected_status("bbbb");
1522        let mut accept_rx = status.enable_accept();
1523
1524        // The peer's introduced ClientHello arrives first → we accept it as a server-role circuit.
1525        status.route_relayed("aaaa", vec![0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0, 0, 0, 0]);
1526        assert!(status.open_tunnel_exists("aaaa"));
1527        let _server_tunnel = accept_rx
1528            .try_recv()
1529            .expect("introduced circuit surfaced as a server");
1530
1531        // Our own dial to that peer now must be REFUSED — the existing circuit is the connection.
1532        let dial = status.open_tunnel("aaaa", DEFAULT_NETWORK_ID);
1533        assert!(
1534            dial.is_err(),
1535            "a second dial to a peer we already serve is refused (no double-session)"
1536        );
1537        assert_eq!(
1538            status.tunnels.lock().unwrap().len(),
1539            1,
1540            "exactly one circuit per peer — never a conflicting client+server pair"
1541        );
1542    }
1543
1544    /// REGRESSION (#1536 equal-id): a relayed self-dial, or a frame stamped with our OWN peer_id
1545    /// (theoretical SPKI collision / a hostile relay reflecting our id), has no lower/higher end for
1546    /// the tie-break — it MUST be rejected outright, never producing a no-server hang.
1547    #[test]
1548    fn self_dial_and_self_stamped_frame_rejected() {
1549        let status = connected_status("cccc");
1550        assert!(
1551            status.open_tunnel("cccc", DEFAULT_NETWORK_ID).is_err(),
1552            "a relayed self-dial (target == local id) is refused"
1553        );
1554
1555        let mut accept_rx = status.enable_accept();
1556        status.route_relayed("cccc", vec![0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0, 0, 0, 0]);
1557        assert!(
1558            !status.open_tunnel_exists("cccc"),
1559            "a frame stamped with our own id is dropped, never registered"
1560        );
1561        assert!(
1562            accept_rx.try_recv().is_err(),
1563            "a self-stamped frame is never surfaced as an accept"
1564        );
1565    }
1566
1567    /// SECURITY (#1536 relay-injected-ClientHello DoS): an untrusted relay can inject a bogus
1568    /// ClientHello on a lower-id node's client tunnel to force it to yield its outbound dial to a
1569    /// server accept that no real peer completes. mTLS identity is never bypassed, but the outbound
1570    /// dial MUST NOT be permanently lost — once the bogus (never-completing) server circuit is dropped,
1571    /// the peer key frees and a fresh dial is possible.
1572    #[test]
1573    fn injected_clienthello_yield_does_not_permanently_block_redial() {
1574        // local id "00aa" is numerically lower than the peer "ffff", so we are the yield-to-server side.
1575        let status = connected_status("00aa");
1576        let mut accept_rx = status.enable_accept();
1577
1578        let client_tunnel = status
1579            .open_tunnel("ffff", DEFAULT_NETWORK_ID)
1580            .expect("outbound relayed dial opens");
1581        assert!(status.open_tunnel_exists("ffff"));
1582
1583        // The relay injects a bogus ClientHello with from=ffff → glare on our client tunnel → we (the
1584        // lower id) yield: drop the client tunnel, surface a server accept.
1585        status.route_relayed("ffff", vec![0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0, 0, 0, 0]);
1586        let server_tunnel = accept_rx
1587            .try_recv()
1588            .expect("the injected ClientHello yielded a server accept");
1589
1590        // The original outbound dial is cancelled; dropping its handle must NOT evict the newer server
1591        // entry (generation-id guard).
1592        drop(client_tunnel);
1593        assert!(
1594            status.open_tunnel_exists("ffff"),
1595            "the server circuit survives the cancelled client dial's drop"
1596        );
1597
1598        // The bogus circuit completes no handshake; once its tunnel is dropped the key frees...
1599        drop(server_tunnel);
1600        assert!(
1601            !status.open_tunnel_exists("ffff"),
1602            "dropping the never-completing server circuit releases the peer key"
1603        );
1604        // ...and a fresh dial is possible — no permanent lockout from the injected frame.
1605        assert!(
1606            status.open_tunnel("ffff", DEFAULT_NETWORK_ID).is_ok(),
1607            "a fresh outbound dial succeeds after the bogus injection is cleaned up"
1608        );
1609    }
1610}