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::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
21use std::sync::{Arc, Mutex};
22use std::time::Duration;
23
24use futures_util::{SinkExt, StreamExt};
25use tokio_tungstenite::tungstenite::Message;
26
27use crate::wire::RelayMessage;
28
29/// Default network id a node registers under (matches dig-gossip `DEFAULT_INTRODUCER_NETWORK_ID`
30/// and dig-node's `DEFAULT_NETWORK_ID`).
31pub const DEFAULT_NETWORK_ID: &str = "DIG_MAINNET";
32
33/// Relay protocol version the node advertises in `Register` (RLY-001).
34pub const RELAY_PROTOCOL_VERSION: u32 = 1;
35
36/// Base reconnect delay (dig-gossip `RelayConfig::reconnect_delay_secs` = 5).
37const BASE_BACKOFF_SECS: u64 = 5;
38/// Cap on the exponential backoff so a long outage doesn't push the retry interval to hours.
39const MAX_BACKOFF_SECS: u64 = 300;
40/// Keepalive ping period (RLY-006; dig-gossip `PING_INTERVAL_SECS` = 30).
41const PING_INTERVAL_SECS: u64 = 30;
42
43/// Compute the next reconnect backoff: capped exponential in the number of consecutive failures.
44/// `failures == 0` → base; doubles each failure up to [`MAX_BACKOFF_SECS`]. Pure → unit-tested.
45pub fn backoff_secs(consecutive_failures: u32) -> u64 {
46    backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
47}
48
49/// Capped-exponential backoff with an explicit base + cap. Always returns a value in `[base, cap]`
50/// — never zero — so a failing connect can never busy-loop.
51fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
52    let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
53    shifted.clamp(base, cap)
54}
55
56/// Backoff schedule for the reconnect loop — production defaults, or fast values for tests.
57#[derive(Debug, Clone, Copy)]
58pub struct Backoff {
59    /// First-retry delay (seconds).
60    pub base_secs: u64,
61    /// Upper bound on the delay (seconds).
62    pub cap_secs: u64,
63}
64
65impl Default for Backoff {
66    fn default() -> Self {
67        Backoff {
68            base_secs: BASE_BACKOFF_SECS,
69            cap_secs: MAX_BACKOFF_SECS,
70        }
71    }
72}
73
74/// The four observable states of the relay reservation, surfaced verbatim (lowercase) as the
75/// `state` field of a `control.relayStatus`-style RPC.
76///
77/// - `Disabled` — reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
78/// - `Connecting` — actively dialing/registering.
79/// - `Connected` — a reservation is held (`RegisterAck{success:true}` arrived); reachable to peers.
80/// - `Disconnected` — not connected; backing off + will retry. The graceful-fallback resting state.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum RelayState {
83    /// Reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
84    Disabled,
85    /// Actively dialing/registering (initial attempt or a reconnect in flight).
86    Connecting,
87    /// A reservation is held (`RegisterAck{success:true}` arrived); reachable to NAT'd peers.
88    Connected,
89    /// Not connected; backing off + will retry. The graceful-fallback resting state.
90    Disconnected,
91}
92
93impl RelayState {
94    /// The stable lowercase wire string for the RPC `state` field.
95    pub fn as_str(self) -> &'static str {
96        match self {
97            RelayState::Disabled => "disabled",
98            RelayState::Connecting => "connecting",
99            RelayState::Connected => "connected",
100            RelayState::Disconnected => "disconnected",
101        }
102    }
103
104    fn to_u8(self) -> u8 {
105        match self {
106            RelayState::Disabled => 0,
107            RelayState::Connecting => 1,
108            RelayState::Connected => 2,
109            RelayState::Disconnected => 3,
110        }
111    }
112
113    fn from_u8(v: u8) -> Self {
114        match v {
115            0 => RelayState::Disabled,
116            1 => RelayState::Connecting,
117            2 => RelayState::Connected,
118            _ => RelayState::Disconnected,
119        }
120    }
121}
122
123/// Live relay-connection status, shared (via `Arc`) between the connection task and an RPC handler.
124/// Cheap atomic reads. State setters do STATE-CHANGE-ONLY logging so a long outage never hot-loops
125/// identical error lines.
126#[derive(Debug)]
127pub struct RelayStatus {
128    state: AtomicU8,
129    reconnect_attempts: AtomicU32,
130    connected_peers: AtomicU64,
131    last_error: Mutex<Option<String>>,
132}
133
134impl Default for RelayStatus {
135    fn default() -> Self {
136        RelayStatus {
137            state: AtomicU8::new(RelayState::Disconnected.to_u8()),
138            reconnect_attempts: AtomicU32::new(0),
139            connected_peers: AtomicU64::new(0),
140            last_error: Mutex::new(None),
141        }
142    }
143}
144
145impl RelayStatus {
146    /// A fresh status (resting `Disconnected` until the task runs / the relay is reached).
147    pub fn new() -> Arc<Self> {
148        Arc::new(RelayStatus::default())
149    }
150
151    /// Read the current state.
152    pub fn state(&self) -> RelayState {
153        RelayState::from_u8(self.state.load(Ordering::Relaxed))
154    }
155
156    /// Transition to `next`, returning `true` IFF the state actually changed. Callers use the return
157    /// to log ONCE per transition (no hot-loop spam).
158    fn transition_to(&self, next: RelayState) -> bool {
159        let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
160        prev != next.to_u8()
161    }
162
163    /// Enter `Disabled` (reservation off). Idempotent; logs only on the first entry.
164    pub fn set_disabled(&self) {
165        if self.transition_to(RelayState::Disabled) {
166            tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
167        }
168    }
169
170    /// Enter `Connecting`. Logs only on the transition (so reconnect attempts don't spam).
171    pub fn set_connecting(&self) {
172        if self.transition_to(RelayState::Connecting) {
173            tracing::debug!("relay connecting");
174        }
175    }
176
177    /// Mark `Connected` (clears the last error, resets the attempt counter). Logs recovery once.
178    pub fn set_connected(&self, connected_peers: u64) {
179        self.connected_peers
180            .store(connected_peers, Ordering::Relaxed);
181        self.reconnect_attempts.store(0, Ordering::Relaxed);
182        *self.last_error.lock().unwrap() = None;
183        if self.transition_to(RelayState::Connected) {
184            tracing::info!(connected_peers, "relay reservation established");
185        }
186    }
187
188    /// Mark `Disconnected` with an optional error and bump the attempt counter. Logs the failure
189    /// ONLY on the transition into `Disconnected` (the first drop); subsequent failed retries while
190    /// already `Disconnected` update the error/counter SILENTLY.
191    pub fn set_disconnected(&self, error: Option<String>) {
192        self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
193        if let Some(e) = &error {
194            *self.last_error.lock().unwrap() = Some(e.clone());
195        }
196        let changed = self.transition_to(RelayState::Disconnected);
197        if changed {
198            match &error {
199                Some(e) => tracing::warn!(
200                    error = %e,
201                    "relay reservation lost — node still serving; retrying in background"
202                ),
203                None => tracing::info!("relay reservation closed — retrying in background"),
204            }
205        }
206    }
207
208    /// Whether a relay session is currently held.
209    pub fn is_connected(&self) -> bool {
210        self.state() == RelayState::Connected
211    }
212
213    /// The current reconnect-attempt count (for tests / RPC).
214    pub fn reconnect_attempts(&self) -> u32 {
215        self.reconnect_attempts.load(Ordering::Relaxed)
216    }
217
218    /// A JSON snapshot for a `control.relayStatus`-style RPC. `state` is the canonical truth;
219    /// `connected` is a convenience boolean (== `state == connected`).
220    pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
221        let state = self.state();
222        serde_json::json!({
223            "state": state.as_str(),
224            "connected": state == RelayState::Connected,
225            "endpoint": endpoint,
226            "peer_id": peer_id,
227            "reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
228            "connected_peers": self.connected_peers.load(Ordering::Relaxed),
229            "last_error": *self.last_error.lock().unwrap(),
230        })
231    }
232}
233
234/// Resolve the relay endpoint: `DIG_RELAY_URL` if set + non-empty (and not the opt-out token), else
235/// the canonical [`dig_constants::DIG_RELAY_URL`].
236pub fn relay_url_from_env() -> String {
237    std::env::var("DIG_RELAY_URL")
238        .ok()
239        .filter(|s| !s.trim().is_empty())
240        .filter(|s| !is_off_token(s))
241        .unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
242}
243
244/// Whether the relay connection is enabled. Disabled when `DIG_RELAY_URL` is `off`/`disabled`/
245/// empty-after-trim — an explicit opt-out for air-gapped/standalone nodes.
246pub fn relay_enabled() -> bool {
247    match std::env::var("DIG_RELAY_URL") {
248        Ok(v) => !is_off_token(&v),
249        Err(_) => true,
250    }
251}
252
253/// `true` if `v` is the reservation opt-out token (`off`/`disabled`, case-insensitive, trimmed).
254fn is_off_token(v: &str) -> bool {
255    let v = v.trim();
256    v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
257}
258
259/// Current unix time (seconds), saturating.
260fn now_secs() -> u64 {
261    std::time::SystemTime::now()
262        .duration_since(std::time::UNIX_EPOCH)
263        .map(|d| d.as_secs())
264        .unwrap_or(0)
265}
266
267/// Maintain a CONSTANT relay reservation forever: connect, register, keepalive, and on any drop
268/// reconnect with capped exponential backoff. Spawned as a background task; tolerates the relay
269/// being down (retries forever, never crashes). `peer_id` is the node's stable identity hex.
270pub async fn run_relay_connection(
271    endpoint: String,
272    peer_id: String,
273    network_id: String,
274    status: Arc<RelayStatus>,
275) {
276    run_relay_connection_with(endpoint, peer_id, network_id, status, Backoff::default()).await
277}
278
279/// [`run_relay_connection`] with an explicit backoff schedule (tests pass tiny values for fast,
280/// deterministic reconnect timing; the LOGIC is identical — only the sleep durations differ).
281pub async fn run_relay_connection_with(
282    endpoint: String,
283    peer_id: String,
284    network_id: String,
285    status: Arc<RelayStatus>,
286    backoff: Backoff,
287) {
288    let mut consecutive_failures: u32 = 0;
289    loop {
290        status.set_connecting();
291        match connect_once(&endpoint, &peer_id, &network_id, &status).await {
292            Ok(()) => {
293                consecutive_failures = 0;
294                status.set_disconnected(None);
295            }
296            Err(e) => {
297                consecutive_failures = consecutive_failures.saturating_add(1);
298                status.set_disconnected(Some(e));
299            }
300        }
301        // ALWAYS sleep a bounded backoff before retrying — prevents a busy error loop.
302        let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
303        tokio::time::sleep(Duration::from_secs(delay)).await;
304    }
305}
306
307/// One connect → register → serve cycle. Returns `Ok` on a clean close, `Err(reason)` on failure.
308async fn connect_once(
309    endpoint: &str,
310    peer_id: &str,
311    network_id: &str,
312    status: &Arc<RelayStatus>,
313) -> Result<(), String> {
314    let (ws, _resp) = tokio_tungstenite::connect_async(endpoint)
315        .await
316        .map_err(|e| format!("connect: {e}"))?;
317    let (mut write, mut read) = ws.split();
318
319    // RLY-001: register immediately so the relay holds our reservation.
320    let register = RelayMessage::Register {
321        peer_id: peer_id.to_string(),
322        network_id: network_id.to_string(),
323        protocol_version: RELAY_PROTOCOL_VERSION,
324    };
325    send(&mut write, &register).await?;
326
327    let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
328    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
329    ping.tick().await; // skip the immediate first tick
330
331    loop {
332        tokio::select! {
333            _ = ping.tick() => {
334                send(&mut write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
335            }
336            frame = read.next() => {
337                match frame {
338                    None => return Ok(()),
339                    Some(Err(e)) => return Err(format!("read: {e}")),
340                    Some(Ok(Message::Close(_))) => return Ok(()),
341                    Some(Ok(Message::Ping(p))) => {
342                        write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
343                    }
344                    Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
345                    Some(Ok(Message::Text(t))) => {
346                        handle_incoming(t.into_bytes(), &mut write, status).await?;
347                    }
348                    Some(Ok(Message::Binary(b))) => {
349                        handle_incoming(b, &mut write, status).await?;
350                    }
351                }
352            }
353        }
354    }
355}
356
357/// Handle one decoded inbound relay frame: track RegisterAck (→ connected), answer relay Pings.
358async fn handle_incoming<W>(
359    bytes: Vec<u8>,
360    write: &mut W,
361    status: &Arc<RelayStatus>,
362) -> Result<(), String>
363where
364    W: SinkExt<Message> + Unpin,
365    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
366{
367    let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
368        return Ok(()); // ignore anything we can't parse; the relay is untrusted
369    };
370    match msg {
371        RelayMessage::RegisterAck {
372            success,
373            message,
374            connected_peers,
375        } => {
376            if success {
377                status.set_connected(connected_peers as u64);
378            } else {
379                return Err(format!("register rejected: {message}"));
380            }
381        }
382        RelayMessage::Ping { timestamp } => {
383            send(write, &RelayMessage::Pong { timestamp }).await?;
384        }
385        RelayMessage::Error { code, message } => {
386            return Err(format!("relay error {code}: {message}"));
387        }
388        other => tracing::debug!(?other, "relay message ignored by reservation loop"),
389    }
390    Ok(())
391}
392
393/// Serialize + send one `RelayMessage` as a WebSocket text frame.
394async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
395where
396    W: SinkExt<Message> + Unpin,
397    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
398{
399    let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
400    write
401        .send(Message::Text(txt))
402        .await
403        .map_err(|e| format!("send: {e}"))
404}