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::HashSet;
21use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::Duration;
24
25use futures_util::{SinkExt, StreamExt};
26use tokio_tungstenite::tungstenite::Message;
27
28use crate::wire::{RelayMessage, RelayPeerInfo};
29
30/// Default network id a node registers under (matches dig-gossip `DEFAULT_INTRODUCER_NETWORK_ID`
31/// and dig-node's `DEFAULT_NETWORK_ID`).
32pub const DEFAULT_NETWORK_ID: &str = "DIG_MAINNET";
33
34/// Relay protocol version the node advertises in `Register` (RLY-001).
35pub const RELAY_PROTOCOL_VERSION: u32 = 1;
36
37/// Base reconnect delay (dig-gossip `RelayConfig::reconnect_delay_secs` = 5).
38const BASE_BACKOFF_SECS: u64 = 5;
39/// Cap on the exponential backoff so a long outage doesn't push the retry interval to hours.
40const MAX_BACKOFF_SECS: u64 = 300;
41/// Keepalive ping period (RLY-006; dig-gossip `PING_INTERVAL_SECS` = 30).
42const PING_INTERVAL_SECS: u64 = 30;
43/// How often the held reservation re-pulls the relay peer list (RLY-005 `GetPeers`) over the SAME
44/// persistent socket, so a peer that registers AFTER this node — or one missed on the first pull —
45/// is still discovered without ever reopening the connection (the connect-leg fix).
46const DISCOVERY_INTERVAL_SECS: u64 = 60;
47
48/// Hard cap on the peers retained in the discovered set ([`RelayStatus::known_peers`]).
49///
50/// SECURITY: the relay is an UNTRUSTED intermediary. A hostile/compromised relay can stream an
51/// unbounded flood of `PeerConnected` frames — or a single oversized `Peers` frame — with distinct
52/// fabricated `peer_id`s, so an uncapped set is a memory-exhaustion DoS. 1024 is far more than any
53/// honest relay reports for one network's live reservations (the set is folded into a peer pool that
54/// itself selects a small working subset), yet small enough that the worst case is bounded, cheap
55/// memory. Beyond the cap, further distinct peers are DROPPED rather than grown.
56pub const MAX_KNOWN_PEERS: usize = 1024;
57
58/// Compute the next reconnect backoff: capped exponential in the number of consecutive failures.
59/// `failures == 0` → base; doubles each failure up to [`MAX_BACKOFF_SECS`]. Pure → unit-tested.
60pub fn backoff_secs(consecutive_failures: u32) -> u64 {
61    backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
62}
63
64/// Capped-exponential backoff with an explicit base + cap. Always returns a value in `[base, cap]`
65/// — never zero — so a failing connect can never busy-loop.
66fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
67    let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
68    shifted.clamp(base, cap)
69}
70
71/// Backoff schedule for the reconnect loop — production defaults, or fast values for tests.
72#[derive(Debug, Clone, Copy)]
73pub struct Backoff {
74    /// First-retry delay (seconds).
75    pub base_secs: u64,
76    /// Upper bound on the delay (seconds).
77    pub cap_secs: u64,
78}
79
80impl Default for Backoff {
81    fn default() -> Self {
82        Backoff {
83            base_secs: BASE_BACKOFF_SECS,
84            cap_secs: MAX_BACKOFF_SECS,
85        }
86    }
87}
88
89/// The four observable states of the relay reservation, surfaced verbatim (lowercase) as the
90/// `state` field of a `control.relayStatus`-style RPC.
91///
92/// - `Disabled` — reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
93/// - `Connecting` — actively dialing/registering.
94/// - `Connected` — a reservation is held (`RegisterAck{success:true}` arrived); reachable to peers.
95/// - `Disconnected` — not connected; backing off + will retry. The graceful-fallback resting state.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum RelayState {
98    /// Reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
99    Disabled,
100    /// Actively dialing/registering (initial attempt or a reconnect in flight).
101    Connecting,
102    /// A reservation is held (`RegisterAck{success:true}` arrived); reachable to NAT'd peers.
103    Connected,
104    /// Not connected; backing off + will retry. The graceful-fallback resting state.
105    Disconnected,
106}
107
108impl RelayState {
109    /// The stable lowercase wire string for the RPC `state` field.
110    pub fn as_str(self) -> &'static str {
111        match self {
112            RelayState::Disabled => "disabled",
113            RelayState::Connecting => "connecting",
114            RelayState::Connected => "connected",
115            RelayState::Disconnected => "disconnected",
116        }
117    }
118
119    fn to_u8(self) -> u8 {
120        match self {
121            RelayState::Disabled => 0,
122            RelayState::Connecting => 1,
123            RelayState::Connected => 2,
124            RelayState::Disconnected => 3,
125        }
126    }
127
128    fn from_u8(v: u8) -> Self {
129        match v {
130            0 => RelayState::Disabled,
131            1 => RelayState::Connecting,
132            2 => RelayState::Connected,
133            _ => RelayState::Disconnected,
134        }
135    }
136}
137
138/// The peers discovered over the live reservation socket, in insertion order with O(1) dedup +
139/// membership by `peer_id`, bounded to [`MAX_KNOWN_PEERS`].
140///
141/// `order` preserves discovery order so [`RelayStatus::known_peers`] returns a stable sequence;
142/// `ids` mirrors `order`'s `peer_id`s so dedup and removal are O(1) instead of a linear scan (the
143/// old `iter().any(...)` was O(n²) over a flood). The two are kept in lockstep — every mutation
144/// touches both.
145#[derive(Debug, Default)]
146struct DiscoveredPeers {
147    order: Vec<RelayPeerInfo>,
148    ids: HashSet<String>,
149}
150
151impl DiscoveredPeers {
152    /// Insert `peer` unless already present or the set is full. Returns nothing — a full set simply
153    /// drops the newcomer (the untrusted-relay flood defense).
154    fn insert(&mut self, peer: RelayPeerInfo) {
155        if self.order.len() >= MAX_KNOWN_PEERS {
156            return;
157        }
158        if self.ids.insert(peer.peer_id.clone()) {
159            self.order.push(peer);
160        }
161    }
162
163    /// Remove the peer with this `peer_id`, if present.
164    fn remove(&mut self, peer_id: &str) {
165        if self.ids.remove(peer_id) {
166            self.order.retain(|p| p.peer_id != peer_id);
167        }
168    }
169
170    /// Replace the whole set from a `Peers` frame, deduped + truncated to the cap.
171    fn replace(&mut self, peers: Vec<RelayPeerInfo>) {
172        self.order.clear();
173        self.ids.clear();
174        for peer in peers {
175            self.insert(peer);
176        }
177    }
178
179    fn clear(&mut self) {
180        self.order.clear();
181        self.ids.clear();
182    }
183}
184
185/// Live relay-connection status, shared (via `Arc`) between the connection task and an RPC handler.
186/// Cheap atomic reads. State setters do STATE-CHANGE-ONLY logging so a long outage never hot-loops
187/// identical error lines.
188#[derive(Debug)]
189pub struct RelayStatus {
190    state: AtomicU8,
191    reconnect_attempts: AtomicU32,
192    connected_peers: AtomicU64,
193    last_error: Mutex<Option<String>>,
194    /// Peers learned over the LIVE reservation socket — the relay's `GetPeers` response (RLY-005)
195    /// plus `PeerConnected`/`PeerDisconnected` pushes. This is the discovery output of the persistent
196    /// reservation: a consumer (dig-gossip's pool/address book) reads it instead of reopening an
197    /// ephemeral socket per pass. Keyed by `peer_id` (deduped); bounded to [`MAX_KNOWN_PEERS`] so an
198    /// untrusted relay can't exhaust memory; cleared on every reconnect so a stale list is never
199    /// served across a drop.
200    known_peers: Mutex<DiscoveredPeers>,
201}
202
203impl Default for RelayStatus {
204    fn default() -> Self {
205        RelayStatus {
206            state: AtomicU8::new(RelayState::Disconnected.to_u8()),
207            reconnect_attempts: AtomicU32::new(0),
208            connected_peers: AtomicU64::new(0),
209            last_error: Mutex::new(None),
210            known_peers: Mutex::new(DiscoveredPeers::default()),
211        }
212    }
213}
214
215impl RelayStatus {
216    /// A fresh status (resting `Disconnected` until the task runs / the relay is reached).
217    pub fn new() -> Arc<Self> {
218        Arc::new(RelayStatus::default())
219    }
220
221    /// Read the current state.
222    pub fn state(&self) -> RelayState {
223        RelayState::from_u8(self.state.load(Ordering::Relaxed))
224    }
225
226    /// Transition to `next`, returning `true` IFF the state actually changed. Callers use the return
227    /// to log ONCE per transition (no hot-loop spam).
228    fn transition_to(&self, next: RelayState) -> bool {
229        let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
230        prev != next.to_u8()
231    }
232
233    /// Enter `Disabled` (reservation off). Idempotent; logs only on the first entry.
234    pub fn set_disabled(&self) {
235        if self.transition_to(RelayState::Disabled) {
236            tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
237        }
238    }
239
240    /// Enter `Connecting`. Logs only on the transition (so reconnect attempts don't spam).
241    pub fn set_connecting(&self) {
242        if self.transition_to(RelayState::Connecting) {
243            tracing::debug!("relay connecting");
244        }
245    }
246
247    /// Mark `Connected` (clears the last error, resets the attempt counter). Logs recovery once.
248    pub fn set_connected(&self, connected_peers: u64) {
249        self.connected_peers
250            .store(connected_peers, Ordering::Relaxed);
251        self.reconnect_attempts.store(0, Ordering::Relaxed);
252        *self.last_error.lock().unwrap() = None;
253        if self.transition_to(RelayState::Connected) {
254            tracing::info!(connected_peers, "relay reservation established");
255        }
256    }
257
258    /// Mark `Disconnected` with an optional error and bump the attempt counter. Logs the failure
259    /// ONLY on the transition into `Disconnected` (the first drop); subsequent failed retries while
260    /// already `Disconnected` update the error/counter SILENTLY.
261    pub fn set_disconnected(&self, error: Option<String>) {
262        self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
263        if let Some(e) = &error {
264            *self.last_error.lock().unwrap() = Some(e.clone());
265        }
266        let changed = self.transition_to(RelayState::Disconnected);
267        if changed {
268            match &error {
269                Some(e) => tracing::warn!(
270                    error = %e,
271                    "relay reservation lost — node still serving; retrying in background"
272                ),
273                None => tracing::info!("relay reservation closed — retrying in background"),
274            }
275        }
276    }
277
278    /// Whether a relay session is currently held.
279    pub fn is_connected(&self) -> bool {
280        self.state() == RelayState::Connected
281    }
282
283    /// The current reconnect-attempt count (for tests / RPC).
284    pub fn reconnect_attempts(&self) -> u32 {
285        self.reconnect_attempts.load(Ordering::Relaxed)
286    }
287
288    /// Snapshot of the peers discovered over the live reservation socket (RLY-005 `Peers` +
289    /// `PeerConnected` pushes, minus `PeerDisconnected`). The consumer folds these into its address
290    /// book / pool. Returns a clone so the caller holds no lock.
291    pub fn known_peers(&self) -> Vec<RelayPeerInfo> {
292        self.known_peers.lock().unwrap().order.clone()
293    }
294
295    /// Count of peers currently discovered over the live reservation socket.
296    pub fn known_peer_count(&self) -> usize {
297        self.known_peers.lock().unwrap().order.len()
298    }
299
300    /// Replace the discovered-peer set with a `GetPeers` response (RLY-005 `Peers`), deduped and
301    /// truncated to [`MAX_KNOWN_PEERS`] (an untrusted relay could send an oversized frame).
302    fn replace_known_peers(&self, peers: Vec<RelayPeerInfo>) {
303        self.known_peers.lock().unwrap().replace(peers);
304    }
305
306    /// Fold in a relay-pushed `PeerConnected` notice, deduped by `peer_id`; dropped once the set is
307    /// full ([`MAX_KNOWN_PEERS`]) so a flood can't exhaust memory.
308    fn add_known_peer(&self, peer: RelayPeerInfo) {
309        self.known_peers.lock().unwrap().insert(peer);
310    }
311
312    /// Drop a peer on a relay-pushed `PeerDisconnected` notice.
313    fn remove_known_peer(&self, peer_id: &str) {
314        self.known_peers.lock().unwrap().remove(peer_id);
315    }
316
317    /// Clear the discovered-peer set (on every reconnect — the list is per-session).
318    fn clear_known_peers(&self) {
319        self.known_peers.lock().unwrap().clear();
320    }
321
322    /// A JSON snapshot for a `control.relayStatus`-style RPC. `state` is the canonical truth;
323    /// `connected` is a convenience boolean (== `state == connected`).
324    pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
325        let state = self.state();
326        serde_json::json!({
327            "state": state.as_str(),
328            "connected": state == RelayState::Connected,
329            "endpoint": endpoint,
330            "peer_id": peer_id,
331            "reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
332            "connected_peers": self.connected_peers.load(Ordering::Relaxed),
333            "last_error": *self.last_error.lock().unwrap(),
334        })
335    }
336}
337
338/// Resolve the relay endpoint: `DIG_RELAY_URL` if set + non-empty (and not the opt-out token), else
339/// the canonical [`dig_constants::DIG_RELAY_URL`].
340pub fn relay_url_from_env() -> String {
341    std::env::var("DIG_RELAY_URL")
342        .ok()
343        .filter(|s| !s.trim().is_empty())
344        .filter(|s| !is_off_token(s))
345        .unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
346}
347
348/// Whether the relay connection is enabled. Disabled when `DIG_RELAY_URL` is `off`/`disabled`/
349/// empty-after-trim — an explicit opt-out for air-gapped/standalone nodes.
350pub fn relay_enabled() -> bool {
351    match std::env::var("DIG_RELAY_URL") {
352        Ok(v) => !is_off_token(&v),
353        Err(_) => true,
354    }
355}
356
357/// `true` if `v` is the reservation opt-out token (`off`/`disabled`, case-insensitive, trimmed).
358fn is_off_token(v: &str) -> bool {
359    let v = v.trim();
360    v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
361}
362
363/// Current unix time (seconds), saturating.
364fn now_secs() -> u64 {
365    std::time::SystemTime::now()
366        .duration_since(std::time::UNIX_EPOCH)
367        .map(|d| d.as_secs())
368        .unwrap_or(0)
369}
370
371/// Maintain a CONSTANT relay reservation forever: connect, register, keepalive, and on any drop
372/// reconnect with capped exponential backoff. Spawned as a background task; tolerates the relay
373/// being down (retries forever, never crashes). `peer_id` is the node's stable identity hex.
374pub async fn run_relay_connection(
375    endpoint: String,
376    peer_id: String,
377    network_id: String,
378    status: Arc<RelayStatus>,
379) {
380    run_relay_connection_with(endpoint, peer_id, network_id, status, Backoff::default()).await
381}
382
383/// [`run_relay_connection`] with an explicit backoff schedule (tests pass tiny values for fast,
384/// deterministic reconnect timing; the LOGIC is identical — only the sleep durations differ).
385pub async fn run_relay_connection_with(
386    endpoint: String,
387    peer_id: String,
388    network_id: String,
389    status: Arc<RelayStatus>,
390    backoff: Backoff,
391) {
392    let mut consecutive_failures: u32 = 0;
393    loop {
394        status.set_connecting();
395        match connect_once(&endpoint, &peer_id, &network_id, &status).await {
396            Ok(()) => {
397                consecutive_failures = 0;
398                status.set_disconnected(None);
399            }
400            Err(e) => {
401                consecutive_failures = consecutive_failures.saturating_add(1);
402                status.set_disconnected(Some(e));
403            }
404        }
405        // ALWAYS sleep a bounded backoff before retrying — prevents a busy error loop.
406        let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
407        tokio::time::sleep(Duration::from_secs(delay)).await;
408    }
409}
410
411/// One connect → register → serve cycle. Returns `Ok` on a clean close, `Err(reason)` on failure.
412async fn connect_once(
413    endpoint: &str,
414    peer_id: &str,
415    network_id: &str,
416    status: &Arc<RelayStatus>,
417) -> Result<(), String> {
418    // Each session's discovered-peer set is independent — never serve a stale list across a drop.
419    status.clear_known_peers();
420
421    let (ws, _resp) = tokio_tungstenite::connect_async(endpoint)
422        .await
423        .map_err(|e| format!("connect: {e}"))?;
424    let (mut write, mut read) = ws.split();
425
426    // RLY-001: register immediately so the relay holds our reservation.
427    let register = RelayMessage::Register {
428        peer_id: peer_id.to_string(),
429        network_id: network_id.to_string(),
430        protocol_version: RELAY_PROTOCOL_VERSION,
431    };
432    send(&mut write, &register).await?;
433
434    // RLY-005: pull the current peer list right away, then again periodically — all over THIS
435    // persistent socket, so discovery never requires reopening a connection.
436    let get_peers = RelayMessage::GetPeers {
437        network_id: Some(network_id.to_string()),
438    };
439    send(&mut write, &get_peers).await?;
440
441    let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
442    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
443    ping.tick().await; // skip the immediate first tick
444
445    let mut discovery = tokio::time::interval(Duration::from_secs(DISCOVERY_INTERVAL_SECS));
446    discovery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
447    discovery.tick().await; // skip the immediate first tick (we already pulled once above)
448
449    loop {
450        tokio::select! {
451            _ = ping.tick() => {
452                send(&mut write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
453            }
454            _ = discovery.tick() => {
455                send(&mut write, &RelayMessage::GetPeers {
456                    network_id: Some(network_id.to_string()),
457                }).await?;
458            }
459            frame = read.next() => {
460                match frame {
461                    None => return Ok(()),
462                    Some(Err(e)) => return Err(format!("read: {e}")),
463                    Some(Ok(Message::Close(_))) => return Ok(()),
464                    Some(Ok(Message::Ping(p))) => {
465                        write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
466                    }
467                    Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
468                    Some(Ok(Message::Text(t))) => {
469                        handle_incoming(t.into_bytes(), &mut write, status).await?;
470                    }
471                    Some(Ok(Message::Binary(b))) => {
472                        handle_incoming(b, &mut write, status).await?;
473                    }
474                }
475            }
476        }
477    }
478}
479
480/// Handle one decoded inbound relay frame: track RegisterAck (→ connected), answer relay Pings.
481async fn handle_incoming<W>(
482    bytes: Vec<u8>,
483    write: &mut W,
484    status: &Arc<RelayStatus>,
485) -> Result<(), String>
486where
487    W: SinkExt<Message> + Unpin,
488    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
489{
490    let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
491        return Ok(()); // ignore anything we can't parse; the relay is untrusted
492    };
493    match msg {
494        RelayMessage::RegisterAck {
495            success,
496            message,
497            connected_peers,
498        } => {
499            if success {
500                status.set_connected(connected_peers as u64);
501            } else {
502                return Err(format!("register rejected: {message}"));
503            }
504        }
505        RelayMessage::Ping { timestamp } => {
506            send(write, &RelayMessage::Pong { timestamp }).await?;
507        }
508        // RLY-005 + push notices: fold peers discovered over the live socket into the status so the
509        // consumer's pool/address book sees them without opening an ephemeral discovery connection.
510        RelayMessage::Peers { peers } => status.replace_known_peers(peers),
511        RelayMessage::PeerConnected { peer } => status.add_known_peer(peer),
512        RelayMessage::PeerDisconnected { peer_id } => status.remove_known_peer(&peer_id),
513        RelayMessage::Error { code, message } => {
514            return Err(format!("relay error {code}: {message}"));
515        }
516        other => tracing::debug!(?other, "relay message ignored by reservation loop"),
517    }
518    Ok(())
519}
520
521/// Serialize + send one `RelayMessage` as a WebSocket text frame.
522async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
523where
524    W: SinkExt<Message> + Unpin,
525    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
526{
527    let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
528    write
529        .send(Message::Text(txt))
530        .await
531        .map_err(|e| format!("send: {e}"))
532}