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::SocketAddr;
22use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::Duration;
25
26use futures_util::{SinkExt, StreamExt};
27use tokio::sync::mpsc;
28use tokio_tungstenite::tungstenite::Message;
29
30use crate::wire::{RelayMessage, RelayPeerInfo};
31
32/// Default network id a node registers under (matches dig-gossip `DEFAULT_INTRODUCER_NETWORK_ID`
33/// and dig-node's `DEFAULT_NETWORK_ID`).
34pub const DEFAULT_NETWORK_ID: &str = "DIG_MAINNET";
35
36/// Relay protocol version the node advertises in `Register` (RLY-001).
37pub const RELAY_PROTOCOL_VERSION: u32 = 1;
38
39/// Base reconnect delay (dig-gossip `RelayConfig::reconnect_delay_secs` = 5).
40const BASE_BACKOFF_SECS: u64 = 5;
41/// Cap on the exponential backoff so a long outage doesn't push the retry interval to hours.
42const MAX_BACKOFF_SECS: u64 = 300;
43/// Keepalive ping period (RLY-006; dig-gossip `PING_INTERVAL_SECS` = 30).
44const PING_INTERVAL_SECS: u64 = 30;
45/// How often the held reservation re-pulls the relay peer list (RLY-005 `GetPeers`) over the SAME
46/// persistent socket, so a peer that registers AFTER this node — or one missed on the first pull —
47/// is still discovered without ever reopening the connection (the connect-leg fix).
48const DISCOVERY_INTERVAL_SECS: u64 = 60;
49
50/// Hard cap on the peers retained in the discovered set ([`RelayStatus::known_peers`]).
51///
52/// SECURITY: the relay is an UNTRUSTED intermediary. A hostile/compromised relay can stream an
53/// unbounded flood of `PeerConnected` frames — or a single oversized `Peers` frame — with distinct
54/// fabricated `peer_id`s, so an uncapped set is a memory-exhaustion DoS. 1024 is far more than any
55/// honest relay reports for one network's live reservations (the set is folded into a peer pool that
56/// itself selects a small working subset), yet small enough that the worst case is bounded, cheap
57/// memory. Beyond the cap, further distinct peers are DROPPED rather than grown.
58pub const MAX_KNOWN_PEERS: usize = 1024;
59
60/// Hard cap on the byte length of a single RLY-002 relayed-transport payload (both directions).
61///
62/// SECURITY / backpressure: the relay is UNTRUSTED and a peer reached over relayed transport is the
63/// last-resort TURN path, so an oversized frame is refused rather than buffered — an outbound `send`
64/// larger than this errors, and an inbound frame larger than this is dropped. 1 MiB comfortably
65/// holds a sealed gossip message (NC-1 ciphertext) while bounding the worst-case per-frame memory.
66pub const MAX_RELAY_PAYLOAD: usize = 1 << 20;
67
68/// Bounded inbound capacity for one open [`RelayTunnel`]. A full channel applies backpressure — the
69/// reservation loop `try_send`s inbound relayed bytes and DROPS the frame when the consumer is not
70/// keeping up, so a hostile relay flooding one tunnel cannot exhaust memory (matches the
71/// [`MAX_KNOWN_PEERS`] bounded-set philosophy). The RLY-002 `seq` lets the consumer detect the gap.
72const RELAY_TUNNEL_INBOUND_CAP: usize = 256;
73
74/// Compute the next reconnect backoff: capped exponential in the number of consecutive failures.
75/// `failures == 0` → base; doubles each failure up to [`MAX_BACKOFF_SECS`]. Pure → unit-tested.
76pub fn backoff_secs(consecutive_failures: u32) -> u64 {
77    backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
78}
79
80/// Capped-exponential backoff with an explicit base + cap. Always returns a value in `[base, cap]`
81/// — never zero — so a failing connect can never busy-loop.
82fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
83    let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
84    shifted.clamp(base, cap)
85}
86
87/// Backoff schedule for the reconnect loop — production defaults, or fast values for tests.
88#[derive(Debug, Clone, Copy)]
89pub struct Backoff {
90    /// First-retry delay (seconds).
91    pub base_secs: u64,
92    /// Upper bound on the delay (seconds).
93    pub cap_secs: u64,
94}
95
96impl Default for Backoff {
97    fn default() -> Self {
98        Backoff {
99            base_secs: BASE_BACKOFF_SECS,
100            cap_secs: MAX_BACKOFF_SECS,
101        }
102    }
103}
104
105/// The four observable states of the relay reservation, surfaced verbatim (lowercase) as the
106/// `state` field of a `control.relayStatus`-style RPC.
107///
108/// - `Disabled` — reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
109/// - `Connecting` — actively dialing/registering.
110/// - `Connected` — a reservation is held (`RegisterAck{success:true}` arrived); reachable to peers.
111/// - `Disconnected` — not connected; backing off + will retry. The graceful-fallback resting state.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RelayState {
114    /// Reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
115    Disabled,
116    /// Actively dialing/registering (initial attempt or a reconnect in flight).
117    Connecting,
118    /// A reservation is held (`RegisterAck{success:true}` arrived); reachable to NAT'd peers.
119    Connected,
120    /// Not connected; backing off + will retry. The graceful-fallback resting state.
121    Disconnected,
122}
123
124impl RelayState {
125    /// The stable lowercase wire string for the RPC `state` field.
126    pub fn as_str(self) -> &'static str {
127        match self {
128            RelayState::Disabled => "disabled",
129            RelayState::Connecting => "connecting",
130            RelayState::Connected => "connected",
131            RelayState::Disconnected => "disconnected",
132        }
133    }
134
135    fn to_u8(self) -> u8 {
136        match self {
137            RelayState::Disabled => 0,
138            RelayState::Connecting => 1,
139            RelayState::Connected => 2,
140            RelayState::Disconnected => 3,
141        }
142    }
143
144    fn from_u8(v: u8) -> Self {
145        match v {
146            0 => RelayState::Disabled,
147            1 => RelayState::Connecting,
148            2 => RelayState::Connected,
149            _ => RelayState::Disconnected,
150        }
151    }
152}
153
154/// The peers discovered over the live reservation socket, in insertion order with O(1) dedup +
155/// membership by `peer_id`, bounded to [`MAX_KNOWN_PEERS`].
156///
157/// `order` preserves discovery order so [`RelayStatus::known_peers`] returns a stable sequence;
158/// `ids` mirrors `order`'s `peer_id`s so dedup and removal are O(1) instead of a linear scan (the
159/// old `iter().any(...)` was O(n²) over a flood). The two are kept in lockstep — every mutation
160/// touches both.
161#[derive(Debug, Default)]
162struct DiscoveredPeers {
163    order: Vec<RelayPeerInfo>,
164    ids: HashSet<String>,
165}
166
167impl DiscoveredPeers {
168    /// Insert `peer` unless already present or the set is full. Returns nothing — a full set simply
169    /// drops the newcomer (the untrusted-relay flood defense).
170    fn insert(&mut self, peer: RelayPeerInfo) {
171        if self.order.len() >= MAX_KNOWN_PEERS {
172            return;
173        }
174        if self.ids.insert(peer.peer_id.clone()) {
175            self.order.push(peer);
176        }
177    }
178
179    /// Remove the peer with this `peer_id`, if present.
180    fn remove(&mut self, peer_id: &str) {
181        if self.ids.remove(peer_id) {
182            self.order.retain(|p| p.peer_id != peer_id);
183        }
184    }
185
186    /// Replace the whole set from a `Peers` frame, deduped + truncated to the cap.
187    fn replace(&mut self, peers: Vec<RelayPeerInfo>) {
188        self.order.clear();
189        self.ids.clear();
190        for peer in peers {
191            self.insert(peer);
192        }
193    }
194
195    fn clear(&mut self) {
196        self.order.clear();
197        self.ids.clear();
198    }
199}
200
201/// Live relay-connection status, shared (via `Arc`) between the connection task and an RPC handler.
202/// Cheap atomic reads. State setters do STATE-CHANGE-ONLY logging so a long outage never hot-loops
203/// identical error lines.
204#[derive(Debug)]
205pub struct RelayStatus {
206    state: AtomicU8,
207    reconnect_attempts: AtomicU32,
208    connected_peers: AtomicU64,
209    last_error: Mutex<Option<String>>,
210    /// Peers learned over the LIVE reservation socket — the relay's `GetPeers` response (RLY-005)
211    /// plus `PeerConnected`/`PeerDisconnected` pushes. This is the discovery output of the persistent
212    /// reservation: a consumer (dig-gossip's pool/address book) reads it instead of reopening an
213    /// ephemeral socket per pass. Keyed by `peer_id` (deduped); bounded to [`MAX_KNOWN_PEERS`] so an
214    /// untrusted relay can't exhaust memory; cleared on every reconnect so a stale list is never
215    /// served across a drop.
216    known_peers: Mutex<DiscoveredPeers>,
217    /// Sink that injects an outbound [`RelayMessage`] into the LIVE reservation socket's write half.
218    /// `Some` only while a session is held (set by `connect_once`, cleared on every drop) — this is
219    /// what lets a [`RelayTunnel`] reuse the ONE persistent reservation socket for RLY-002 relayed
220    /// transport instead of opening a second connection.
221    outbound: Mutex<Option<mpsc::UnboundedSender<RelayMessage>>>,
222    /// This node's own `peer_id` (hex), stamped as `from` on every RLY-002 frame the tunnels send.
223    /// Set when a session registers; needed because a tunnel is opened from the shared status handle.
224    local_peer_id: Mutex<Option<String>>,
225    /// Open relayed-transport tunnels, keyed by the REMOTE peer's `peer_id` (hex). An inbound RLY-002
226    /// `relay_message` from a peer is routed to its tunnel's inbound channel; a frame from a peer with
227    /// no open tunnel is dropped (the untrusted-relay default). Entries are removed on tunnel drop.
228    tunnels: Mutex<HashMap<String, mpsc::Sender<Vec<u8>>>>,
229    /// Monotonic per-node sequence number stamped on outbound RLY-002 frames (ordering/dedup).
230    relay_seq: AtomicU64,
231}
232
233impl Default for RelayStatus {
234    fn default() -> Self {
235        RelayStatus {
236            state: AtomicU8::new(RelayState::Disconnected.to_u8()),
237            reconnect_attempts: AtomicU32::new(0),
238            connected_peers: AtomicU64::new(0),
239            last_error: Mutex::new(None),
240            known_peers: Mutex::new(DiscoveredPeers::default()),
241            outbound: Mutex::new(None),
242            local_peer_id: Mutex::new(None),
243            tunnels: Mutex::new(HashMap::new()),
244            relay_seq: AtomicU64::new(0),
245        }
246    }
247}
248
249impl RelayStatus {
250    /// A fresh status (resting `Disconnected` until the task runs / the relay is reached).
251    pub fn new() -> Arc<Self> {
252        Arc::new(RelayStatus::default())
253    }
254
255    /// Read the current state.
256    pub fn state(&self) -> RelayState {
257        RelayState::from_u8(self.state.load(Ordering::Relaxed))
258    }
259
260    /// Transition to `next`, returning `true` IFF the state actually changed. Callers use the return
261    /// to log ONCE per transition (no hot-loop spam).
262    fn transition_to(&self, next: RelayState) -> bool {
263        let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
264        prev != next.to_u8()
265    }
266
267    /// Enter `Disabled` (reservation off). Idempotent; logs only on the first entry.
268    pub fn set_disabled(&self) {
269        if self.transition_to(RelayState::Disabled) {
270            tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
271        }
272    }
273
274    /// Enter `Connecting`. Logs only on the transition (so reconnect attempts don't spam).
275    pub fn set_connecting(&self) {
276        if self.transition_to(RelayState::Connecting) {
277            tracing::debug!("relay connecting");
278        }
279    }
280
281    /// Mark `Connected` (clears the last error, resets the attempt counter). Logs recovery once.
282    pub fn set_connected(&self, connected_peers: u64) {
283        self.connected_peers
284            .store(connected_peers, Ordering::Relaxed);
285        self.reconnect_attempts.store(0, Ordering::Relaxed);
286        *self.last_error.lock().unwrap() = None;
287        if self.transition_to(RelayState::Connected) {
288            tracing::info!(connected_peers, "relay reservation established");
289        }
290    }
291
292    /// Mark `Disconnected` with an optional error and bump the attempt counter. Logs the failure
293    /// ONLY on the transition into `Disconnected` (the first drop); subsequent failed retries while
294    /// already `Disconnected` update the error/counter SILENTLY.
295    pub fn set_disconnected(&self, error: Option<String>) {
296        self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
297        if let Some(e) = &error {
298            *self.last_error.lock().unwrap() = Some(e.clone());
299        }
300        let changed = self.transition_to(RelayState::Disconnected);
301        if changed {
302            match &error {
303                Some(e) => tracing::warn!(
304                    error = %e,
305                    "relay reservation lost — node still serving; retrying in background"
306                ),
307                None => tracing::info!("relay reservation closed — retrying in background"),
308            }
309        }
310    }
311
312    /// Whether a relay session is currently held.
313    pub fn is_connected(&self) -> bool {
314        self.state() == RelayState::Connected
315    }
316
317    /// The current reconnect-attempt count (for tests / RPC).
318    pub fn reconnect_attempts(&self) -> u32 {
319        self.reconnect_attempts.load(Ordering::Relaxed)
320    }
321
322    /// Snapshot of the peers discovered over the live reservation socket (RLY-005 `Peers` +
323    /// `PeerConnected` pushes, minus `PeerDisconnected`). The consumer folds these into its address
324    /// book / pool. Returns a clone so the caller holds no lock.
325    pub fn known_peers(&self) -> Vec<RelayPeerInfo> {
326        self.known_peers.lock().unwrap().order.clone()
327    }
328
329    /// Count of peers currently discovered over the live reservation socket.
330    pub fn known_peer_count(&self) -> usize {
331        self.known_peers.lock().unwrap().order.len()
332    }
333
334    /// Replace the discovered-peer set with a `GetPeers` response (RLY-005 `Peers`), deduped and
335    /// truncated to [`MAX_KNOWN_PEERS`] (an untrusted relay could send an oversized frame).
336    fn replace_known_peers(&self, peers: Vec<RelayPeerInfo>) {
337        self.known_peers.lock().unwrap().replace(peers);
338    }
339
340    /// Fold in a relay-pushed `PeerConnected` notice, deduped by `peer_id`; dropped once the set is
341    /// full ([`MAX_KNOWN_PEERS`]) so a flood can't exhaust memory.
342    fn add_known_peer(&self, peer: RelayPeerInfo) {
343        self.known_peers.lock().unwrap().insert(peer);
344    }
345
346    /// Drop a peer on a relay-pushed `PeerDisconnected` notice.
347    fn remove_known_peer(&self, peer_id: &str) {
348        self.known_peers.lock().unwrap().remove(peer_id);
349    }
350
351    /// Clear the discovered-peer set (on every reconnect — the list is per-session).
352    fn clear_known_peers(&self) {
353        self.known_peers.lock().unwrap().clear();
354    }
355
356    // -- RLY-002 relayed transport (the tier-6 TURN fallback) ------------------------------------
357    //
358    // A relayed tunnel reuses the ONE persistent reservation socket: outbound frames go through
359    // `outbound` (drained by the reservation loop's write half), inbound `relay_message` frames are
360    // routed by `from` peer_id to the matching tunnel. Available only while the reservation is held.
361
362    /// Install the live session's outbound sink + this node's `peer_id`. Called by `connect_once`
363    /// once registered; cleared by [`clear_transport`](Self::clear_transport) on every drop.
364    fn set_transport(&self, peer_id: &str, outbound: mpsc::UnboundedSender<RelayMessage>) {
365        *self.local_peer_id.lock().unwrap() = Some(peer_id.to_string());
366        *self.outbound.lock().unwrap() = Some(outbound);
367    }
368
369    /// Tear down the transport on session drop: drop the outbound sink (so tunnel sends fail fast)
370    /// and close every open tunnel's inbound channel (so a blocked `recv` wakes with `None`).
371    fn clear_transport(&self) {
372        *self.outbound.lock().unwrap() = None;
373        self.tunnels.lock().unwrap().clear();
374    }
375
376    /// Whether a relayed tunnel can currently be opened — a reservation is held AND its outbound sink
377    /// is live. The tier-6 [`RelayedTransport`](crate::method::relayed::RelayedTransport) gates on this.
378    pub fn relay_transport_ready(&self) -> bool {
379        self.is_connected() && self.outbound.lock().unwrap().is_some()
380    }
381
382    /// Open an RLY-002 relayed-transport tunnel to `target_peer` (hex `peer_id`) over the held
383    /// reservation socket — the traversal ladder's FINAL tier when a pair can neither direct-dial nor
384    /// hole-punch. The returned [`RelayTunnel`] sends/receives opaque payloads that the relay forwards
385    /// A→relay→B; per NC-1 the payload is END-TO-END SEALED to the recipient so the relay forwards
386    /// ciphertext only. `Err` if no reservation is held. Dropping the tunnel deregisters it.
387    pub fn open_tunnel(
388        self: &Arc<Self>,
389        target_peer: &str,
390        network_id: &str,
391    ) -> Result<RelayTunnel, String> {
392        if !self.relay_transport_ready() {
393            return Err("relay reservation not connected — cannot open relayed tunnel".into());
394        }
395        let (tx, rx) = mpsc::channel(RELAY_TUNNEL_INBOUND_CAP);
396        self.tunnels
397            .lock()
398            .unwrap()
399            .insert(target_peer.to_string(), tx);
400        Ok(RelayTunnel {
401            target: target_peer.to_string(),
402            network_id: network_id.to_string(),
403            status: Arc::clone(self),
404            inbound: rx,
405        })
406    }
407
408    /// Route one inbound RLY-002 `relay_message` to its tunnel by `from` peer_id. Oversized payloads
409    /// are dropped (size cap); a frame from a peer with no open tunnel is dropped; a full inbound
410    /// channel drops the frame (backpressure). Returns silently in every drop case (untrusted relay).
411    fn route_relayed(&self, from: &str, payload: Vec<u8>) {
412        if payload.len() > MAX_RELAY_PAYLOAD {
413            tracing::debug!(
414                from,
415                len = payload.len(),
416                "dropping oversized relayed frame"
417            );
418            return;
419        }
420        let sink = self.tunnels.lock().unwrap().get(from).cloned();
421        if let Some(sink) = sink {
422            if sink.try_send(payload).is_err() {
423                tracing::debug!(from, "relayed tunnel inbound full/closed — frame dropped");
424            }
425        }
426    }
427
428    /// Remove a tunnel's routing entry (called on [`RelayTunnel`] drop).
429    fn close_tunnel(&self, target_peer: &str) {
430        self.tunnels.lock().unwrap().remove(target_peer);
431    }
432
433    /// A JSON snapshot for a `control.relayStatus`-style RPC. `state` is the canonical truth;
434    /// `connected` is a convenience boolean (== `state == connected`).
435    pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
436        let state = self.state();
437        serde_json::json!({
438            "state": state.as_str(),
439            "connected": state == RelayState::Connected,
440            "endpoint": endpoint,
441            "peer_id": peer_id,
442            "reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
443            "connected_peers": self.connected_peers.load(Ordering::Relaxed),
444            "last_error": *self.last_error.lock().unwrap(),
445        })
446    }
447}
448
449/// A live RLY-002 relayed-transport tunnel to one peer, multiplexed over the node's persistent relay
450/// reservation socket (the tier-6 TURN fallback). Writes are framed as RLY-002 `relay_message` to the
451/// target and forwarded A→relay→B; reads are the payloads the relay forwards back from that peer.
452///
453/// Per NC-1 the payload MUST be END-TO-END SEALED to the recipient's key by the caller — the relay is
454/// an untrusted forwarder that sees only ciphertext. Dropping the tunnel deregisters its routing.
455pub struct RelayTunnel {
456    /// The remote peer's `peer_id` (hex) — the RLY-002 `to`, and the routing key for inbound frames.
457    target: String,
458    /// The network the tunnel is scoped to (echoed for the consumer; relay routes by peer_id).
459    network_id: String,
460    /// Shared status handle — provides the live outbound sink, this node's `peer_id`, and the seq.
461    status: Arc<RelayStatus>,
462    /// Inbound payloads the relay forwarded from `target`, in arrival order (bounded — see
463    /// [`RELAY_TUNNEL_INBOUND_CAP`]).
464    inbound: mpsc::Receiver<Vec<u8>>,
465}
466
467impl RelayTunnel {
468    /// The remote peer this tunnel forwards to/from (hex `peer_id`).
469    pub fn target(&self) -> &str {
470        &self.target
471    }
472
473    /// The network the tunnel is scoped to.
474    pub fn network_id(&self) -> &str {
475        &self.network_id
476    }
477
478    /// Send `payload` to the target peer through the relay (RLY-002 `relay_message`). `payload` MUST
479    /// already be sealed to the recipient (NC-1). `Err` if the reservation dropped (send after the
480    /// session closed) or `payload` exceeds [`MAX_RELAY_PAYLOAD`].
481    pub fn send(&self, payload: Vec<u8>) -> Result<(), String> {
482        if payload.len() > MAX_RELAY_PAYLOAD {
483            return Err(format!(
484                "relayed payload {} exceeds cap {MAX_RELAY_PAYLOAD}",
485                payload.len()
486            ));
487        }
488        let from = self
489            .status
490            .local_peer_id
491            .lock()
492            .unwrap()
493            .clone()
494            .ok_or("relay reservation not connected — no local peer_id")?;
495        let seq = self.status.relay_seq.fetch_add(1, Ordering::Relaxed);
496        let frame = RelayMessage::RelayGossipMessage {
497            from,
498            to: self.target.clone(),
499            payload,
500            seq,
501        };
502        let guard = self.status.outbound.lock().unwrap();
503        let sink = guard
504            .as_ref()
505            .ok_or("relay reservation not connected — cannot send relayed frame")?;
506        sink.send(frame)
507            .map_err(|_| "relay reservation write half closed".to_string())
508    }
509
510    /// Await the next payload the relay forwards from the target peer. `None` once the reservation
511    /// drops (the session closed) — the caller should re-open the tunnel after the relay reconnects.
512    pub async fn recv(&mut self) -> Option<Vec<u8>> {
513        self.inbound.recv().await
514    }
515}
516
517impl Drop for RelayTunnel {
518    fn drop(&mut self) {
519        self.status.close_tunnel(&self.target);
520    }
521}
522
523/// Resolve the relay endpoint: `DIG_RELAY_URL` if set + non-empty (and not the opt-out token), else
524/// the canonical [`dig_constants::DIG_RELAY_URL`].
525pub fn relay_url_from_env() -> String {
526    std::env::var("DIG_RELAY_URL")
527        .ok()
528        .filter(|s| !s.trim().is_empty())
529        .filter(|s| !is_off_token(s))
530        .unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
531}
532
533/// Whether the relay connection is enabled. Disabled when `DIG_RELAY_URL` is `off`/`disabled`/
534/// empty-after-trim — an explicit opt-out for air-gapped/standalone nodes.
535pub fn relay_enabled() -> bool {
536    match std::env::var("DIG_RELAY_URL") {
537        Ok(v) => !is_off_token(&v),
538        Err(_) => true,
539    }
540}
541
542/// `true` if `v` is the reservation opt-out token (`off`/`disabled`, case-insensitive, trimmed).
543fn is_off_token(v: &str) -> bool {
544    let v = v.trim();
545    v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
546}
547
548/// Current unix time (seconds), saturating.
549fn now_secs() -> u64 {
550    std::time::SystemTime::now()
551        .duration_since(std::time::UNIX_EPOCH)
552        .map(|d| d.as_secs())
553        .unwrap_or(0)
554}
555
556/// Maintain a CONSTANT relay reservation forever: connect, register, keepalive, and on any drop
557/// reconnect with capped exponential backoff. Spawned as a background task; tolerates the relay
558/// being down (retries forever, never crashes). `peer_id` is the node's stable identity hex.
559pub async fn run_relay_connection(
560    endpoint: String,
561    peer_id: String,
562    network_id: String,
563    listen_addrs: Vec<SocketAddr>,
564    status: Arc<RelayStatus>,
565) {
566    run_relay_connection_with(
567        endpoint,
568        peer_id,
569        network_id,
570        listen_addrs,
571        status,
572        Backoff::default(),
573    )
574    .await
575}
576
577/// [`run_relay_connection`] with an explicit backoff schedule (tests pass tiny values for fast,
578/// deterministic reconnect timing; the LOGIC is identical — only the sleep durations differ).
579pub async fn run_relay_connection_with(
580    endpoint: String,
581    peer_id: String,
582    network_id: String,
583    listen_addrs: Vec<SocketAddr>,
584    status: Arc<RelayStatus>,
585    backoff: Backoff,
586) {
587    let mut consecutive_failures: u32 = 0;
588    loop {
589        status.set_connecting();
590        match connect_once(&endpoint, &peer_id, &network_id, &listen_addrs, &status).await {
591            Ok(()) => {
592                consecutive_failures = 0;
593                status.set_disconnected(None);
594            }
595            Err(e) => {
596                consecutive_failures = consecutive_failures.saturating_add(1);
597                status.set_disconnected(Some(e));
598            }
599        }
600        // ALWAYS sleep a bounded backoff before retrying — prevents a busy error loop.
601        let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
602        tokio::time::sleep(Duration::from_secs(delay)).await;
603    }
604}
605
606/// One connect → register → serve cycle. Returns `Ok` on a clean close, `Err(reason)` on failure.
607async fn connect_once(
608    endpoint: &str,
609    peer_id: &str,
610    network_id: &str,
611    listen_addrs: &[SocketAddr],
612    status: &Arc<RelayStatus>,
613) -> Result<(), String> {
614    // Each session's discovered-peer set + transport are independent — never carry state across a
615    // drop. `clear_transport` also runs at the end so a dropped session's tunnels/sink never linger.
616    status.clear_known_peers();
617    status.clear_transport();
618
619    let (ws, _resp) = tokio_tungstenite::connect_async(endpoint)
620        .await
621        .map_err(|e| format!("connect: {e}"))?;
622    let (mut write, mut read) = ws.split();
623
624    // RLY-001: register immediately so the relay holds our reservation, advertising the node's gossip
625    // listen candidates (B1) so the relay can hand other peers a dialable candidate (§5.2 IPv6-first).
626    let register = RelayMessage::Register {
627        peer_id: peer_id.to_string(),
628        network_id: network_id.to_string(),
629        protocol_version: RELAY_PROTOCOL_VERSION,
630        listen_addrs: listen_addrs.to_vec(),
631    };
632    send(&mut write, &register).await?;
633
634    // Publish the outbound sink so RLY-002 relayed tunnels can reuse THIS persistent socket. Drained
635    // in the select loop below; cleared when the session ends.
636    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayMessage>();
637    status.set_transport(peer_id, out_tx);
638
639    // RLY-005: pull the current peer list right away, then again periodically — all over THIS
640    // persistent socket, so discovery never requires reopening a connection.
641    let get_peers = RelayMessage::GetPeers {
642        network_id: Some(network_id.to_string()),
643    };
644    send(&mut write, &get_peers).await?;
645
646    let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
647    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
648    ping.tick().await; // skip the immediate first tick
649
650    let mut discovery = tokio::time::interval(Duration::from_secs(DISCOVERY_INTERVAL_SECS));
651    discovery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
652    discovery.tick().await; // skip the immediate first tick (we already pulled once above)
653
654    // Run the session; whatever the outcome, tear the transport down so a dropped session never
655    // leaves a stale outbound sink or open tunnels behind (they'd send into a closed socket).
656    let result = serve_session(
657        &mut write,
658        &mut read,
659        &mut ping,
660        &mut discovery,
661        &mut out_rx,
662        network_id,
663        status,
664    )
665    .await;
666    status.clear_transport();
667    result
668}
669
670/// The connected-session select loop: keepalive pings, periodic RLY-005 discovery, draining the
671/// outbound relayed-transport sink onto the socket, and handling inbound frames. Returns `Ok` on a
672/// clean close, `Err(reason)` on a failure. Split out of `connect_once` so its caller can always run
673/// transport teardown regardless of how the session ends.
674#[allow(clippy::too_many_arguments)]
675async fn serve_session<W, R>(
676    write: &mut W,
677    read: &mut R,
678    ping: &mut tokio::time::Interval,
679    discovery: &mut tokio::time::Interval,
680    out_rx: &mut mpsc::UnboundedReceiver<RelayMessage>,
681    network_id: &str,
682    status: &Arc<RelayStatus>,
683) -> Result<(), String>
684where
685    W: SinkExt<Message> + Unpin,
686    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
687    R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
688{
689    loop {
690        tokio::select! {
691            _ = ping.tick() => {
692                send(write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
693            }
694            _ = discovery.tick() => {
695                send(write, &RelayMessage::GetPeers {
696                    network_id: Some(network_id.to_string()),
697                }).await?;
698            }
699            // A relayed tunnel queued an RLY-002 frame — forward it over THIS persistent socket.
700            Some(frame) = out_rx.recv() => {
701                send(write, &frame).await?;
702            }
703            frame = read.next() => {
704                match frame {
705                    None => return Ok(()),
706                    Some(Err(e)) => return Err(format!("read: {e}")),
707                    Some(Ok(Message::Close(_))) => return Ok(()),
708                    Some(Ok(Message::Ping(p))) => {
709                        write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
710                    }
711                    Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
712                    Some(Ok(Message::Text(t))) => {
713                        handle_incoming(t.into_bytes(), write, status).await?;
714                    }
715                    Some(Ok(Message::Binary(b))) => {
716                        handle_incoming(b, write, status).await?;
717                    }
718                }
719            }
720        }
721    }
722}
723
724/// Handle one decoded inbound relay frame: track RegisterAck (→ connected), answer relay Pings.
725async fn handle_incoming<W>(
726    bytes: Vec<u8>,
727    write: &mut W,
728    status: &Arc<RelayStatus>,
729) -> Result<(), String>
730where
731    W: SinkExt<Message> + Unpin,
732    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
733{
734    let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
735        return Ok(()); // ignore anything we can't parse; the relay is untrusted
736    };
737    match msg {
738        RelayMessage::RegisterAck {
739            success,
740            message,
741            connected_peers,
742        } => {
743            if success {
744                status.set_connected(connected_peers as u64);
745            } else {
746                return Err(format!("register rejected: {message}"));
747            }
748        }
749        RelayMessage::Ping { timestamp } => {
750            send(write, &RelayMessage::Pong { timestamp }).await?;
751        }
752        // RLY-005 + push notices: fold peers discovered over the live socket into the status so the
753        // consumer's pool/address book sees them without opening an ephemeral discovery connection.
754        RelayMessage::Peers { peers } => status.replace_known_peers(peers),
755        RelayMessage::PeerConnected { peer } => status.add_known_peer(peer),
756        RelayMessage::PeerDisconnected { peer_id } => status.remove_known_peer(&peer_id),
757        // RLY-002 relayed transport (tier-6 TURN): route a payload the relay forwarded from `from` to
758        // that peer's open tunnel. Unknown-peer / oversized / full-channel frames are dropped inside
759        // `route_relayed` (untrusted-relay defense). `to`/`seq` are the relay's concern; we key on
760        // `from`. Per NC-1 `payload` is sealed ciphertext the relay could not read.
761        RelayMessage::RelayGossipMessage { from, payload, .. } => {
762            status.route_relayed(&from, payload)
763        }
764        RelayMessage::Error { code, message } => {
765            return Err(format!("relay error {code}: {message}"));
766        }
767        other => tracing::debug!(?other, "relay message ignored by reservation loop"),
768    }
769    Ok(())
770}
771
772/// Serialize + send one `RelayMessage` as a WebSocket text frame.
773async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
774where
775    W: SinkExt<Message> + Unpin,
776    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
777{
778    let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
779    write
780        .send(Message::Text(txt))
781        .await
782        .map_err(|e| format!("send: {e}"))
783}