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/// Compute the next reconnect backoff: capped exponential in the number of consecutive failures.
78/// `failures == 0` → base; doubles each failure up to [`MAX_BACKOFF_SECS`]. Pure → unit-tested.
79pub fn backoff_secs(consecutive_failures: u32) -> u64 {
80    backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
81}
82
83/// Capped-exponential backoff with an explicit base + cap. Always returns a value in `[base, cap]`
84/// — never zero — so a failing connect can never busy-loop.
85fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
86    let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
87    shifted.clamp(base, cap)
88}
89
90/// Backoff schedule for the reconnect loop — production defaults, or fast values for tests.
91#[derive(Debug, Clone, Copy)]
92pub struct Backoff {
93    /// First-retry delay (seconds).
94    pub base_secs: u64,
95    /// Upper bound on the delay (seconds).
96    pub cap_secs: u64,
97}
98
99impl Default for Backoff {
100    fn default() -> Self {
101        Backoff {
102            base_secs: BASE_BACKOFF_SECS,
103            cap_secs: MAX_BACKOFF_SECS,
104        }
105    }
106}
107
108/// The four observable states of the relay reservation, surfaced verbatim (lowercase) as the
109/// `state` field of a `control.relayStatus`-style RPC.
110///
111/// - `Disabled` — reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
112/// - `Connecting` — actively dialing/registering.
113/// - `Connected` — a reservation is held (`RegisterAck{success:true}` arrived); reachable to peers.
114/// - `Disconnected` — not connected; backing off + will retry. The graceful-fallback resting state.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum RelayState {
117    /// Reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
118    Disabled,
119    /// Actively dialing/registering (initial attempt or a reconnect in flight).
120    Connecting,
121    /// A reservation is held (`RegisterAck{success:true}` arrived); reachable to NAT'd peers.
122    Connected,
123    /// Not connected; backing off + will retry. The graceful-fallback resting state.
124    Disconnected,
125}
126
127impl RelayState {
128    /// The stable lowercase wire string for the RPC `state` field.
129    pub fn as_str(self) -> &'static str {
130        match self {
131            RelayState::Disabled => "disabled",
132            RelayState::Connecting => "connecting",
133            RelayState::Connected => "connected",
134            RelayState::Disconnected => "disconnected",
135        }
136    }
137
138    fn to_u8(self) -> u8 {
139        match self {
140            RelayState::Disabled => 0,
141            RelayState::Connecting => 1,
142            RelayState::Connected => 2,
143            RelayState::Disconnected => 3,
144        }
145    }
146
147    fn from_u8(v: u8) -> Self {
148        match v {
149            0 => RelayState::Disabled,
150            1 => RelayState::Connecting,
151            2 => RelayState::Connected,
152            _ => RelayState::Disconnected,
153        }
154    }
155}
156
157/// The peers discovered over the live reservation socket, in insertion order with O(1) dedup +
158/// membership by `peer_id`, bounded to [`MAX_KNOWN_PEERS`].
159///
160/// `order` preserves discovery order so [`RelayStatus::known_peers`] returns a stable sequence;
161/// `ids` mirrors `order`'s `peer_id`s so dedup and removal are O(1) instead of a linear scan (the
162/// old `iter().any(...)` was O(n²) over a flood). The two are kept in lockstep — every mutation
163/// touches both.
164#[derive(Debug, Default)]
165struct DiscoveredPeers {
166    order: Vec<RelayPeerInfo>,
167    ids: HashSet<String>,
168}
169
170impl DiscoveredPeers {
171    /// Insert `peer` unless already present or the set is full. Returns nothing — a full set simply
172    /// drops the newcomer (the untrusted-relay flood defense).
173    fn insert(&mut self, peer: RelayPeerInfo) {
174        if self.order.len() >= MAX_KNOWN_PEERS {
175            return;
176        }
177        if self.ids.insert(peer.peer_id.clone()) {
178            self.order.push(peer);
179        }
180    }
181
182    /// Remove the peer with this `peer_id`, if present.
183    fn remove(&mut self, peer_id: &str) {
184        if self.ids.remove(peer_id) {
185            self.order.retain(|p| p.peer_id != peer_id);
186        }
187    }
188
189    /// Replace the whole set from a `Peers` frame, deduped + truncated to the cap.
190    fn replace(&mut self, peers: Vec<RelayPeerInfo>) {
191        self.order.clear();
192        self.ids.clear();
193        for peer in peers {
194            self.insert(peer);
195        }
196    }
197
198    fn clear(&mut self) {
199        self.order.clear();
200        self.ids.clear();
201    }
202}
203
204/// Live relay-connection status, shared (via `Arc`) between the connection task and an RPC handler.
205/// Cheap atomic reads. State setters do STATE-CHANGE-ONLY logging so a long outage never hot-loops
206/// identical error lines.
207#[derive(Debug)]
208pub struct RelayStatus {
209    state: AtomicU8,
210    reconnect_attempts: AtomicU32,
211    connected_peers: AtomicU64,
212    last_error: Mutex<Option<String>>,
213    /// Peers learned over the LIVE reservation socket — the relay's `GetPeers` response (RLY-005)
214    /// plus `PeerConnected`/`PeerDisconnected` pushes. This is the discovery output of the persistent
215    /// reservation: a consumer (dig-gossip's pool/address book) reads it instead of reopening an
216    /// ephemeral socket per pass. Keyed by `peer_id` (deduped); bounded to [`MAX_KNOWN_PEERS`] so an
217    /// untrusted relay can't exhaust memory; cleared on every reconnect so a stale list is never
218    /// served across a drop.
219    known_peers: Mutex<DiscoveredPeers>,
220    /// Sink that injects an outbound [`RelayMessage`] into the LIVE reservation socket's write half.
221    /// `Some` only while a session is held (set by `connect_once`, cleared on every drop) — this is
222    /// what lets a [`RelayTunnel`] reuse the ONE persistent reservation socket for RLY-002 relayed
223    /// transport instead of opening a second connection.
224    outbound: Mutex<Option<mpsc::UnboundedSender<RelayMessage>>>,
225    /// This node's own `peer_id` (hex), stamped as `from` on every RLY-002 frame the tunnels send.
226    /// Set when a session registers; needed because a tunnel is opened from the shared status handle.
227    local_peer_id: Mutex<Option<String>>,
228    /// Open relayed-transport tunnels, keyed by the REMOTE peer's `peer_id` (hex). An inbound RLY-002
229    /// `relay_message` from a peer is routed to its tunnel's inbound channel; a frame from a peer with
230    /// no open tunnel is dropped (the untrusted-relay default). Entries are removed on tunnel drop.
231    tunnels: Mutex<HashMap<String, mpsc::Sender<Vec<u8>>>>,
232    /// Monotonic per-node sequence number stamped on outbound RLY-002 frames (ordering/dedup).
233    relay_seq: AtomicU64,
234}
235
236impl Default for RelayStatus {
237    fn default() -> Self {
238        RelayStatus {
239            state: AtomicU8::new(RelayState::Disconnected.to_u8()),
240            reconnect_attempts: AtomicU32::new(0),
241            connected_peers: AtomicU64::new(0),
242            last_error: Mutex::new(None),
243            known_peers: Mutex::new(DiscoveredPeers::default()),
244            outbound: Mutex::new(None),
245            local_peer_id: Mutex::new(None),
246            tunnels: Mutex::new(HashMap::new()),
247            relay_seq: AtomicU64::new(0),
248        }
249    }
250}
251
252impl RelayStatus {
253    /// A fresh status (resting `Disconnected` until the task runs / the relay is reached).
254    pub fn new() -> Arc<Self> {
255        Arc::new(RelayStatus::default())
256    }
257
258    /// Read the current state.
259    pub fn state(&self) -> RelayState {
260        RelayState::from_u8(self.state.load(Ordering::Relaxed))
261    }
262
263    /// Transition to `next`, returning `true` IFF the state actually changed. Callers use the return
264    /// to log ONCE per transition (no hot-loop spam).
265    fn transition_to(&self, next: RelayState) -> bool {
266        let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
267        prev != next.to_u8()
268    }
269
270    /// Enter `Disabled` (reservation off). Idempotent; logs only on the first entry.
271    pub fn set_disabled(&self) {
272        if self.transition_to(RelayState::Disabled) {
273            tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
274        }
275    }
276
277    /// Enter `Connecting`. Logs only on the transition (so reconnect attempts don't spam).
278    pub fn set_connecting(&self) {
279        if self.transition_to(RelayState::Connecting) {
280            tracing::debug!("relay connecting");
281        }
282    }
283
284    /// Mark `Connected` (clears the last error, resets the attempt counter). Logs recovery once.
285    pub fn set_connected(&self, connected_peers: u64) {
286        self.connected_peers
287            .store(connected_peers, Ordering::Relaxed);
288        self.reconnect_attempts.store(0, Ordering::Relaxed);
289        *self.last_error.lock().unwrap() = None;
290        if self.transition_to(RelayState::Connected) {
291            tracing::info!(connected_peers, "relay reservation established");
292        }
293    }
294
295    /// Mark `Disconnected` with an optional error and bump the attempt counter. Logs the failure
296    /// ONLY on the transition into `Disconnected` (the first drop); subsequent failed retries while
297    /// already `Disconnected` update the error/counter SILENTLY.
298    pub fn set_disconnected(&self, error: Option<String>) {
299        self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
300        if let Some(e) = &error {
301            *self.last_error.lock().unwrap() = Some(e.clone());
302        }
303        let changed = self.transition_to(RelayState::Disconnected);
304        if changed {
305            match &error {
306                Some(e) => tracing::warn!(
307                    error = %e,
308                    "relay reservation lost — node still serving; retrying in background"
309                ),
310                None => tracing::info!("relay reservation closed — retrying in background"),
311            }
312        }
313    }
314
315    /// Whether a relay session is currently held.
316    pub fn is_connected(&self) -> bool {
317        self.state() == RelayState::Connected
318    }
319
320    /// The current reconnect-attempt count (for tests / RPC).
321    pub fn reconnect_attempts(&self) -> u32 {
322        self.reconnect_attempts.load(Ordering::Relaxed)
323    }
324
325    /// Snapshot of the peers discovered over the live reservation socket (RLY-005 `Peers` +
326    /// `PeerConnected` pushes, minus `PeerDisconnected`). The consumer folds these into its address
327    /// book / pool. Returns a clone so the caller holds no lock.
328    pub fn known_peers(&self) -> Vec<RelayPeerInfo> {
329        self.known_peers.lock().unwrap().order.clone()
330    }
331
332    /// Count of peers currently discovered over the live reservation socket.
333    pub fn known_peer_count(&self) -> usize {
334        self.known_peers.lock().unwrap().order.len()
335    }
336
337    /// Replace the discovered-peer set with a `GetPeers` response (RLY-005 `Peers`), deduped and
338    /// truncated to [`MAX_KNOWN_PEERS`] (an untrusted relay could send an oversized frame).
339    fn replace_known_peers(&self, peers: Vec<RelayPeerInfo>) {
340        self.known_peers.lock().unwrap().replace(peers);
341    }
342
343    /// Fold in a relay-pushed `PeerConnected` notice, deduped by `peer_id`; dropped once the set is
344    /// full ([`MAX_KNOWN_PEERS`]) so a flood can't exhaust memory.
345    fn add_known_peer(&self, peer: RelayPeerInfo) {
346        self.known_peers.lock().unwrap().insert(peer);
347    }
348
349    /// Drop a peer on a relay-pushed `PeerDisconnected` notice.
350    fn remove_known_peer(&self, peer_id: &str) {
351        self.known_peers.lock().unwrap().remove(peer_id);
352    }
353
354    /// Clear the discovered-peer set (on every reconnect — the list is per-session).
355    fn clear_known_peers(&self) {
356        self.known_peers.lock().unwrap().clear();
357    }
358
359    // -- RLY-002 relayed transport (the tier-6 TURN fallback) ------------------------------------
360    //
361    // A relayed tunnel reuses the ONE persistent reservation socket: outbound frames go through
362    // `outbound` (drained by the reservation loop's write half), inbound `relay_message` frames are
363    // routed by `from` peer_id to the matching tunnel. Available only while the reservation is held.
364
365    /// Install the live session's outbound sink + this node's `peer_id`. Called by `connect_once`
366    /// once registered; cleared by [`clear_transport`](Self::clear_transport) on every drop.
367    fn set_transport(&self, peer_id: &str, outbound: mpsc::UnboundedSender<RelayMessage>) {
368        *self.local_peer_id.lock().unwrap() = Some(peer_id.to_string());
369        *self.outbound.lock().unwrap() = Some(outbound);
370    }
371
372    /// Tear down the transport on session drop: drop the outbound sink (so tunnel sends fail fast)
373    /// and close every open tunnel's inbound channel (so a blocked `recv` wakes with `None`).
374    fn clear_transport(&self) {
375        *self.outbound.lock().unwrap() = None;
376        self.tunnels.lock().unwrap().clear();
377    }
378
379    /// Whether a relayed tunnel can currently be opened — a reservation is held AND its outbound sink
380    /// is live. The tier-6 [`RelayedTransport`](crate::method::relayed::RelayedTransport) gates on this.
381    pub fn relay_transport_ready(&self) -> bool {
382        self.is_connected() && self.outbound.lock().unwrap().is_some()
383    }
384
385    /// Open an RLY-002 relayed-transport tunnel to `target_peer` (hex `peer_id`) over the held
386    /// reservation socket — the traversal ladder's FINAL tier when a pair can neither direct-dial nor
387    /// hole-punch. The returned [`RelayTunnel`] sends/receives opaque payloads that the relay forwards
388    /// A→relay→B; per NC-1 the payload is END-TO-END SEALED to the recipient so the relay forwards
389    /// ciphertext only. `Err` if no reservation is held. Dropping the tunnel deregisters it.
390    pub fn open_tunnel(
391        self: &Arc<Self>,
392        target_peer: &str,
393        network_id: &str,
394    ) -> Result<RelayTunnel, String> {
395        if !self.relay_transport_ready() {
396            return Err("relay reservation not connected — cannot open relayed tunnel".into());
397        }
398        let (tx, rx) = mpsc::channel(RELAY_TUNNEL_INBOUND_CAP);
399        self.tunnels
400            .lock()
401            .unwrap()
402            .insert(target_peer.to_string(), tx);
403        Ok(RelayTunnel {
404            target: target_peer.to_string(),
405            network_id: network_id.to_string(),
406            status: Arc::clone(self),
407            inbound: rx,
408        })
409    }
410
411    /// Route one inbound RLY-002 `relay_message` to its tunnel by `from` peer_id. Oversized payloads
412    /// are dropped (size cap); a frame from a peer with no open tunnel is dropped; a full inbound
413    /// channel drops the frame (backpressure). Returns silently in every drop case (untrusted relay).
414    fn route_relayed(&self, from: &str, payload: Vec<u8>) {
415        if payload.len() > MAX_RELAY_PAYLOAD {
416            tracing::debug!(
417                from,
418                len = payload.len(),
419                "dropping oversized relayed frame"
420            );
421            return;
422        }
423        let sink = self.tunnels.lock().unwrap().get(from).cloned();
424        if let Some(sink) = sink {
425            if sink.try_send(payload).is_err() {
426                tracing::debug!(from, "relayed tunnel inbound full/closed — frame dropped");
427            }
428        }
429    }
430
431    /// Remove a tunnel's routing entry (called on [`RelayTunnel`] drop).
432    fn close_tunnel(&self, target_peer: &str) {
433        self.tunnels.lock().unwrap().remove(target_peer);
434    }
435
436    /// Whether a relayed tunnel to `target_peer` is currently registered — the test hook fast-connect
437    /// uses to assert the per-peer tunnel was released (dropped) after a relayed→direct promotion,
438    /// while the reservation itself stays held.
439    #[cfg(test)]
440    pub(crate) fn open_tunnel_exists(&self, target_peer: &str) -> bool {
441        self.tunnels.lock().unwrap().contains_key(target_peer)
442    }
443
444    /// A JSON snapshot for a `control.relayStatus`-style RPC. `state` is the canonical truth;
445    /// `connected` is a convenience boolean (== `state == connected`).
446    pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
447        let state = self.state();
448        serde_json::json!({
449            "state": state.as_str(),
450            "connected": state == RelayState::Connected,
451            "endpoint": endpoint,
452            "peer_id": peer_id,
453            "reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
454            "connected_peers": self.connected_peers.load(Ordering::Relaxed),
455            "last_error": *self.last_error.lock().unwrap(),
456        })
457    }
458}
459
460/// A live RLY-002 relayed-transport tunnel to one peer, multiplexed over the node's persistent relay
461/// reservation socket (the tier-6 TURN fallback). Writes are framed as RLY-002 `relay_message` to the
462/// target and forwarded A→relay→B; reads are the payloads the relay forwards back from that peer.
463///
464/// Per NC-1 the payload MUST be END-TO-END SEALED to the recipient's key by the caller — the relay is
465/// an untrusted forwarder that sees only ciphertext. Dropping the tunnel deregisters its routing.
466pub struct RelayTunnel {
467    /// The remote peer's `peer_id` (hex) — the RLY-002 `to`, and the routing key for inbound frames.
468    target: String,
469    /// The network the tunnel is scoped to (echoed for the consumer; relay routes by peer_id).
470    network_id: String,
471    /// Shared status handle — provides the live outbound sink, this node's `peer_id`, and the seq.
472    status: Arc<RelayStatus>,
473    /// Inbound payloads the relay forwarded from `target`, in arrival order (bounded — see
474    /// [`RELAY_TUNNEL_INBOUND_CAP`]).
475    inbound: mpsc::Receiver<Vec<u8>>,
476}
477
478impl RelayTunnel {
479    /// The remote peer this tunnel forwards to/from (hex `peer_id`).
480    pub fn target(&self) -> &str {
481        &self.target
482    }
483
484    /// The network the tunnel is scoped to.
485    pub fn network_id(&self) -> &str {
486        &self.network_id
487    }
488
489    /// Send `payload` to the target peer through the relay (RLY-002 `relay_message`). `payload` MUST
490    /// already be sealed to the recipient (NC-1). `Err` if the reservation dropped (send after the
491    /// session closed) or `payload` exceeds [`MAX_RELAY_PAYLOAD`].
492    pub fn send(&self, payload: Vec<u8>) -> Result<(), String> {
493        if payload.len() > MAX_RELAY_PAYLOAD {
494            return Err(format!(
495                "relayed payload {} exceeds cap {MAX_RELAY_PAYLOAD}",
496                payload.len()
497            ));
498        }
499        let from = self
500            .status
501            .local_peer_id
502            .lock()
503            .unwrap()
504            .clone()
505            .ok_or("relay reservation not connected — no local peer_id")?;
506        let seq = self.status.relay_seq.fetch_add(1, Ordering::Relaxed);
507        let frame = RelayMessage::RelayGossipMessage {
508            from,
509            to: self.target.clone(),
510            payload,
511            seq,
512        };
513        let guard = self.status.outbound.lock().unwrap();
514        let sink = guard
515            .as_ref()
516            .ok_or("relay reservation not connected — cannot send relayed frame")?;
517        sink.send(frame)
518            .map_err(|_| "relay reservation write half closed".to_string())
519    }
520
521    /// Await the next payload the relay forwards from the target peer. `None` once the reservation
522    /// drops (the session closed) — the caller should re-open the tunnel after the relay reconnects.
523    pub async fn recv(&mut self) -> Option<Vec<u8>> {
524        self.inbound.recv().await
525    }
526
527    /// Poll for the next inbound payload. This is the non-`async` primitive the
528    /// [`RelayTunnelStream`](crate::tunnel::RelayTunnelStream) `AsyncRead` adapter drives so an mTLS
529    /// session can run OVER the relay tunnel. `Poll::Ready(None)` once the reservation drops.
530    pub(crate) fn poll_recv(
531        &mut self,
532        cx: &mut std::task::Context<'_>,
533    ) -> std::task::Poll<Option<Vec<u8>>> {
534        self.inbound.poll_recv(cx)
535    }
536}
537
538impl Drop for RelayTunnel {
539    fn drop(&mut self) {
540        self.status.close_tunnel(&self.target);
541    }
542}
543
544/// Resolve the relay endpoint: `DIG_RELAY_URL` if set + non-empty (and not the opt-out token), else
545/// the canonical [`dig_constants::DIG_RELAY_URL`].
546pub fn relay_url_from_env() -> String {
547    std::env::var("DIG_RELAY_URL")
548        .ok()
549        .filter(|s| !s.trim().is_empty())
550        .filter(|s| !is_off_token(s))
551        .unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
552}
553
554/// Whether the relay connection is enabled. Disabled when `DIG_RELAY_URL` is `off`/`disabled`/
555/// empty-after-trim — an explicit opt-out for air-gapped/standalone nodes.
556pub fn relay_enabled() -> bool {
557    match std::env::var("DIG_RELAY_URL") {
558        Ok(v) => !is_off_token(&v),
559        Err(_) => true,
560    }
561}
562
563/// `true` if `v` is the reservation opt-out token (`off`/`disabled`, case-insensitive, trimmed).
564fn is_off_token(v: &str) -> bool {
565    let v = v.trim();
566    v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
567}
568
569/// Current unix time (seconds), saturating.
570fn now_secs() -> u64 {
571    std::time::SystemTime::now()
572        .duration_since(std::time::UNIX_EPOCH)
573        .map(|d| d.as_secs())
574        .unwrap_or(0)
575}
576
577/// Maintain a CONSTANT relay reservation forever: connect, register, keepalive, and on any drop
578/// reconnect with capped exponential backoff. Spawned as a background task; tolerates the relay
579/// being down (retries forever, never crashes). `peer_id` is the node's stable identity hex.
580pub async fn run_relay_connection(
581    endpoint: String,
582    peer_id: String,
583    network_id: String,
584    listen_addrs: Vec<SocketAddr>,
585    status: Arc<RelayStatus>,
586) {
587    run_relay_connection_with(
588        endpoint,
589        peer_id,
590        network_id,
591        listen_addrs,
592        status,
593        Backoff::default(),
594    )
595    .await
596}
597
598/// [`run_relay_connection`] with an explicit backoff schedule (tests pass tiny values for fast,
599/// deterministic reconnect timing; the LOGIC is identical — only the sleep durations differ).
600pub async fn run_relay_connection_with(
601    endpoint: String,
602    peer_id: String,
603    network_id: String,
604    listen_addrs: Vec<SocketAddr>,
605    status: Arc<RelayStatus>,
606    backoff: Backoff,
607) {
608    let mut consecutive_failures: u32 = 0;
609    loop {
610        status.set_connecting();
611        match connect_once(&endpoint, &peer_id, &network_id, &listen_addrs, &status).await {
612            Ok(()) => {
613                consecutive_failures = 0;
614                status.set_disconnected(None);
615            }
616            Err(e) => {
617                consecutive_failures = consecutive_failures.saturating_add(1);
618                status.set_disconnected(Some(e));
619            }
620        }
621        // ALWAYS sleep a bounded backoff before retrying — prevents a busy error loop.
622        let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
623        tokio::time::sleep(Duration::from_secs(delay)).await;
624    }
625}
626
627/// A relay WebSocket endpoint parsed into the pieces the happy-eyeballs dial needs: the host to
628/// resolve and the TCP port. The scheme (`ws`/`wss`) only selects the default port here — the
629/// plaintext-vs-TLS choice is re-derived from the URL by [`client_async_tls_with_config`] during the
630/// handshake, so a single code path serves both.
631#[derive(Debug, PartialEq, Eq)]
632struct RelayEndpoint {
633    host: String,
634    port: u16,
635}
636
637/// Parse a relay endpoint URL (`ws://host[:port][/path]` / `wss://host[:port][/path]`, IPv6 hosts in
638/// `[…]`) into its host + port. Only the authority is needed for the dial; any path/query/fragment and
639/// userinfo are ignored (the full URL is still handed to the WS handshake for the correct `Host`/SNI).
640fn parse_relay_endpoint(endpoint: &str) -> Result<RelayEndpoint, String> {
641    let (scheme, rest) = endpoint
642        .split_once("://")
643        .ok_or_else(|| format!("relay endpoint missing scheme: {endpoint}"))?;
644    let default_port = match scheme.to_ascii_lowercase().as_str() {
645        "ws" => 80,
646        "wss" => 443,
647        other => return Err(format!("unsupported relay scheme: {other}")),
648    };
649    // Authority only: drop any path/query/fragment, then any `userinfo@`.
650    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
651    let authority = authority
652        .rsplit_once('@')
653        .map(|(_, h)| h)
654        .unwrap_or(authority);
655
656    let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
657        // Bracketed IPv6 literal: `[addr]` or `[addr]:port`.
658        let (h, after) = stripped
659            .split_once(']')
660            .ok_or_else(|| format!("malformed IPv6 authority: {authority}"))?;
661        let port = match after.strip_prefix(':') {
662            Some(p) => p.parse().map_err(|_| format!("bad relay port: {after}"))?,
663            None => default_port,
664        };
665        (h.to_string(), port)
666    } else if let Some((h, p)) = authority.rsplit_once(':') {
667        (
668            h.to_string(),
669            p.parse().map_err(|_| format!("bad relay port: {p}"))?,
670        )
671    } else {
672        (authority.to_string(), default_port)
673    };
674
675    if host.is_empty() {
676        return Err(format!("relay endpoint missing host: {endpoint}"));
677    }
678    Ok(RelayEndpoint { host, port })
679}
680
681/// Resolve a relay host to its family-tagged dial candidates: a literal IP yields one candidate (no
682/// DNS), a hostname is resolved to its full A + AAAA set. The candidates feed `dig_ip::connect`, which
683/// applies the §5.2 IPv6-first preference + local∩peer family intersection, so no ordering is imposed
684/// here — the addresses are added as resolved and tagged by family for observability.
685async fn resolve_relay_candidates(host: &str, port: u16) -> Result<PeerCandidates, String> {
686    let mut candidates = PeerCandidates::new();
687    let source_for = |ip: &IpAddr| {
688        if ip.is_ipv6() {
689            CandidateSource::DnsAAAA
690        } else {
691            CandidateSource::DnsA
692        }
693    };
694    if let Ok(ip) = host.parse::<IpAddr>() {
695        candidates.add(SocketAddr::new(ip, port), source_for(&ip));
696    } else {
697        let resolved = tokio::net::lookup_host((host, port))
698            .await
699            .map_err(|e| format!("resolve {host}:{port}: {e}"))?;
700        for addr in resolved {
701            candidates.add(addr, source_for(&addr.ip()));
702        }
703    }
704    if candidates.is_empty() {
705        return Err(format!("no addresses resolved for {host}:{port}"));
706    }
707    Ok(candidates)
708}
709
710/// Race the relay `candidates` IPv6-first with graceful IPv4 fallback via `dig_ip::connect` (§5.2,
711/// RFC 8305). The transport connect stays a caller-supplied closure so the racing logic is unit-tested
712/// with a fake dial (no real DNS/sockets) exactly as the direct-peer dialer does in `dialer.rs`; the
713/// production caller ([`open_relay_ws`]) hands it a real [`TcpStream::connect`].
714async fn race_relay_candidates<C, F, Fut>(
715    local: &LocalStack,
716    candidates: &PeerCandidates,
717    config: DialConfig,
718    dial_fn: F,
719) -> Result<dig_ip::DialWinner<C>, String>
720where
721    F: Fn(SocketAddr) -> Fut + Sync,
722    Fut: std::future::Future<Output = Result<C, String>> + Send,
723    C: Send,
724{
725    dig_ip::connect(local, candidates, config, dial_fn)
726        .await
727        .map_err(|e| format!("relay happy-eyeballs dial: {e}"))
728}
729
730/// Open the relay WebSocket over an IPv6-first happy-eyeballs TCP race (§5.2), matching the direct-peer
731/// dial path in `dialer.rs`: resolve the endpoint host to its A + AAAA candidates, race the TCP connect
732/// via `dig_ip::connect` (IPv6-first, fast IPv4 fallback), then run the WS handshake over the WINNING
733/// socket — TLS-over-that-stream for `wss://`, plaintext for `ws://` (the mode is taken from the URL by
734/// [`client_async_tls_with_config`]). Replaces `tokio_tungstenite::connect_async`, whose sequential,
735/// single-family resolve-and-connect contradicted the IPv6-first reservation guarantee.
736async fn open_relay_ws(
737    endpoint: &str,
738) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, String> {
739    let parsed = parse_relay_endpoint(endpoint)?;
740    let candidates = resolve_relay_candidates(&parsed.host, parsed.port).await?;
741    let local = LocalStack::cached();
742    let winner = race_relay_candidates(
743        &local,
744        &candidates,
745        DialConfig::default(),
746        |addr| async move {
747            TcpStream::connect(addr)
748                .await
749                .map_err(|e| format!("tcp connect {addr}: {e}"))
750        },
751    )
752    .await?;
753    let (ws, _resp) = client_async_tls_with_config(endpoint, winner.conn, None, None)
754        .await
755        .map_err(|e| format!("ws handshake: {e}"))?;
756    Ok(ws)
757}
758
759/// One connect → register → serve cycle. Returns `Ok` on a clean close, `Err(reason)` on failure.
760async fn connect_once(
761    endpoint: &str,
762    peer_id: &str,
763    network_id: &str,
764    listen_addrs: &[SocketAddr],
765    status: &Arc<RelayStatus>,
766) -> Result<(), String> {
767    // Each session's discovered-peer set + transport are independent — never carry state across a
768    // drop. `clear_transport` also runs at the end so a dropped session's tunnels/sink never linger.
769    status.clear_known_peers();
770    status.clear_transport();
771
772    let ws = open_relay_ws(endpoint).await?;
773    let (mut write, mut read) = ws.split();
774
775    // RLY-001: register immediately so the relay holds our reservation, advertising the node's gossip
776    // listen candidates (B1) so the relay can hand other peers a dialable candidate (§5.2 IPv6-first).
777    let register = RelayMessage::Register {
778        peer_id: peer_id.to_string(),
779        network_id: network_id.to_string(),
780        protocol_version: RELAY_PROTOCOL_VERSION,
781        listen_addrs: listen_addrs.to_vec(),
782    };
783    send(&mut write, &register).await?;
784
785    // Publish the outbound sink so RLY-002 relayed tunnels can reuse THIS persistent socket. Drained
786    // in the select loop below; cleared when the session ends.
787    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayMessage>();
788    status.set_transport(peer_id, out_tx);
789
790    // RLY-005: pull the current peer list right away, then again periodically — all over THIS
791    // persistent socket, so discovery never requires reopening a connection.
792    let get_peers = RelayMessage::GetPeers {
793        network_id: Some(network_id.to_string()),
794    };
795    send(&mut write, &get_peers).await?;
796
797    let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
798    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
799    ping.tick().await; // skip the immediate first tick
800
801    let mut discovery = tokio::time::interval(Duration::from_secs(DISCOVERY_INTERVAL_SECS));
802    discovery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
803    discovery.tick().await; // skip the immediate first tick (we already pulled once above)
804
805    // Run the session; whatever the outcome, tear the transport down so a dropped session never
806    // leaves a stale outbound sink or open tunnels behind (they'd send into a closed socket).
807    let result = serve_session(
808        &mut write,
809        &mut read,
810        &mut ping,
811        &mut discovery,
812        &mut out_rx,
813        network_id,
814        status,
815    )
816    .await;
817    status.clear_transport();
818    result
819}
820
821/// The connected-session select loop: keepalive pings, periodic RLY-005 discovery, draining the
822/// outbound relayed-transport sink onto the socket, and handling inbound frames. Returns `Ok` on a
823/// clean close, `Err(reason)` on a failure. Split out of `connect_once` so its caller can always run
824/// transport teardown regardless of how the session ends.
825#[allow(clippy::too_many_arguments)]
826async fn serve_session<W, R>(
827    write: &mut W,
828    read: &mut R,
829    ping: &mut tokio::time::Interval,
830    discovery: &mut tokio::time::Interval,
831    out_rx: &mut mpsc::UnboundedReceiver<RelayMessage>,
832    network_id: &str,
833    status: &Arc<RelayStatus>,
834) -> Result<(), String>
835where
836    W: SinkExt<Message> + Unpin,
837    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
838    R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
839{
840    loop {
841        tokio::select! {
842            _ = ping.tick() => {
843                send(write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
844            }
845            _ = discovery.tick() => {
846                send(write, &RelayMessage::GetPeers {
847                    network_id: Some(network_id.to_string()),
848                }).await?;
849            }
850            // A relayed tunnel queued an RLY-002 frame — forward it over THIS persistent socket.
851            Some(frame) = out_rx.recv() => {
852                send(write, &frame).await?;
853            }
854            frame = read.next() => {
855                match frame {
856                    None => return Ok(()),
857                    Some(Err(e)) => return Err(format!("read: {e}")),
858                    Some(Ok(Message::Close(_))) => return Ok(()),
859                    Some(Ok(Message::Ping(p))) => {
860                        write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
861                    }
862                    Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
863                    Some(Ok(Message::Text(t))) => {
864                        handle_incoming(t.into_bytes(), write, status).await?;
865                    }
866                    Some(Ok(Message::Binary(b))) => {
867                        handle_incoming(b, write, status).await?;
868                    }
869                }
870            }
871        }
872    }
873}
874
875/// Handle one decoded inbound relay frame: track RegisterAck (→ connected), answer relay Pings.
876async fn handle_incoming<W>(
877    bytes: Vec<u8>,
878    write: &mut W,
879    status: &Arc<RelayStatus>,
880) -> Result<(), String>
881where
882    W: SinkExt<Message> + Unpin,
883    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
884{
885    let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
886        return Ok(()); // ignore anything we can't parse; the relay is untrusted
887    };
888    match msg {
889        RelayMessage::RegisterAck {
890            success,
891            message,
892            connected_peers,
893        } => {
894            if success {
895                status.set_connected(connected_peers as u64);
896            } else {
897                return Err(format!("register rejected: {message}"));
898            }
899        }
900        RelayMessage::Ping { timestamp } => {
901            send(write, &RelayMessage::Pong { timestamp }).await?;
902        }
903        // RLY-005 + push notices: fold peers discovered over the live socket into the status so the
904        // consumer's pool/address book sees them without opening an ephemeral discovery connection.
905        RelayMessage::Peers { peers } => status.replace_known_peers(peers),
906        RelayMessage::PeerConnected { peer } => status.add_known_peer(peer),
907        RelayMessage::PeerDisconnected { peer_id } => status.remove_known_peer(&peer_id),
908        // RLY-002 relayed transport (tier-6 TURN): route a payload the relay forwarded from `from` to
909        // that peer's open tunnel. Unknown-peer / oversized / full-channel frames are dropped inside
910        // `route_relayed` (untrusted-relay defense). `to`/`seq` are the relay's concern; we key on
911        // `from`. Per NC-1 `payload` is sealed ciphertext the relay could not read.
912        RelayMessage::RelayGossipMessage { from, payload, .. } => {
913            status.route_relayed(&from, payload)
914        }
915        RelayMessage::Error { code, message } => {
916            return Err(format!("relay error {code}: {message}"));
917        }
918        other => tracing::debug!(?other, "relay message ignored by reservation loop"),
919    }
920    Ok(())
921}
922
923/// Wire two in-memory relay reservations to forward RLY-002 frames to each OTHER — a loopback relay
924/// with no real network. Each returned [`RelayStatus`] is `Connected` with a live outbound sink whose
925/// frames are routed into the peer's tunnels by `from` peer_id, exactly as a real relay would forward
926/// A→relay→B. Used to prove a full mTLS session round-trips over [`RelayTunnel`]s (see `tunnel.rs`).
927///
928/// `a` opens tunnels targeting `b_id`; `b` opens tunnels targeting `a_id`.
929#[cfg(test)]
930pub(crate) fn loopback_reservation_pair(
931    a_id: &str,
932    b_id: &str,
933) -> (Arc<RelayStatus>, Arc<RelayStatus>) {
934    let a = RelayStatus::new();
935    let b = RelayStatus::new();
936    a.set_connected(1);
937    b.set_connected(1);
938
939    let (a_tx, mut a_rx) = mpsc::unbounded_channel::<RelayMessage>();
940    let (b_tx, mut b_rx) = mpsc::unbounded_channel::<RelayMessage>();
941    a.set_transport(a_id, a_tx);
942    b.set_transport(b_id, b_tx);
943
944    // Drain a's outbound → forward into b (route by `from`), and symmetrically b → a. This is the
945    // relay's forwarding role, in-process.
946    let b_route = Arc::clone(&b);
947    tokio::spawn(async move {
948        while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = a_rx.recv().await {
949            b_route.route_relayed(&from, payload);
950        }
951    });
952    let a_route = Arc::clone(&a);
953    tokio::spawn(async move {
954        while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = b_rx.recv().await {
955            a_route.route_relayed(&from, payload);
956        }
957    });
958
959    (a, b)
960}
961
962/// Serialize + send one `RelayMessage` as a WebSocket text frame.
963async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
964where
965    W: SinkExt<Message> + Unpin,
966    <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
967{
968    let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
969    write
970        .send(Message::Text(txt))
971        .await
972        .map_err(|e| format!("send: {e}"))
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978    use dig_ip::Family;
979    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
980    use std::sync::Mutex as StdMutex;
981
982    #[test]
983    fn parses_wss_host_and_explicit_port() {
984        let ep = parse_relay_endpoint("wss://relay.dig.net:443").unwrap();
985        assert_eq!(ep.host, "relay.dig.net");
986        assert_eq!(ep.port, 443);
987    }
988
989    #[test]
990    fn parses_default_ports_by_scheme() {
991        assert_eq!(
992            parse_relay_endpoint("wss://relay.dig.net").unwrap().port,
993            443
994        );
995        assert_eq!(parse_relay_endpoint("ws://relay.dig.net").unwrap().port, 80);
996    }
997
998    #[test]
999    fn parses_bracketed_ipv6_authority_with_and_without_port() {
1000        let with_port = parse_relay_endpoint("wss://[2001:db8::1]:8443").unwrap();
1001        assert_eq!(with_port.host, "2001:db8::1");
1002        assert_eq!(with_port.port, 8443);
1003        let no_port = parse_relay_endpoint("wss://[2001:db8::1]/ws").unwrap();
1004        assert_eq!(no_port.host, "2001:db8::1");
1005        assert_eq!(no_port.port, 443);
1006    }
1007
1008    #[test]
1009    fn ignores_path_query_and_userinfo() {
1010        let ep = parse_relay_endpoint("wss://user@relay.dig.net:9443/ws?x=1#f").unwrap();
1011        assert_eq!(ep.host, "relay.dig.net");
1012        assert_eq!(ep.port, 9443);
1013    }
1014
1015    #[test]
1016    fn rejects_malformed_endpoints() {
1017        assert!(parse_relay_endpoint("relay.dig.net:443").is_err()); // no scheme
1018        assert!(parse_relay_endpoint("http://relay.dig.net").is_err()); // wrong scheme
1019        assert!(parse_relay_endpoint("wss://relay.dig.net:notaport").is_err());
1020    }
1021
1022    #[tokio::test]
1023    async fn resolve_relay_candidates_handles_ip_literals_without_dns() {
1024        let v6 = resolve_relay_candidates("2001:db8::1", 443).await.unwrap();
1025        assert_eq!(v6.all().len(), 1);
1026        assert_eq!(v6.all()[0].family, Family::V6);
1027        assert_eq!(v6.all()[0].source, CandidateSource::DnsAAAA);
1028
1029        let v4 = resolve_relay_candidates("203.0.113.7", 443).await.unwrap();
1030        assert_eq!(v4.all()[0].family, Family::V4);
1031        assert_eq!(v4.all()[0].source, CandidateSource::DnsA);
1032    }
1033
1034    /// The relay dial races BOTH families and falls back to IPv4 when the IPv6 candidate is dead —
1035    /// the §5.2 happy-eyeballs guarantee, proven with a FAKE dial closure (no real DNS/sockets). A
1036    /// dead IPv6 candidate + a live IPv4 candidate on a dual-stack host must yield the IPv4 winner,
1037    /// and BOTH families must have been attempted.
1038    #[tokio::test]
1039    async fn relay_dial_races_both_families_and_falls_back_to_ipv4() {
1040        let mut candidates = PeerCandidates::new();
1041        let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1042        let v4: SocketAddr = "203.0.113.7:443".parse().unwrap();
1043        candidates.add(v6, CandidateSource::DnsAAAA);
1044        candidates.add(v4, CandidateSource::DnsA);
1045
1046        let dual = LocalStack::from_flags(true, true);
1047        let attempted: StdMutex<Vec<SocketAddr>> = StdMutex::new(Vec::new());
1048        // Fast attempt-delay so the hedged IPv4 starts promptly once the IPv6 attempt fails.
1049        let cfg = DialConfig {
1050            per_attempt_timeout: Duration::from_secs(1),
1051            attempt_delay: Duration::from_millis(5),
1052        };
1053
1054        let winner = race_relay_candidates(&dual, &candidates, cfg, |addr| {
1055            let attempted = &attempted;
1056            async move {
1057                attempted.lock().unwrap().push(addr);
1058                if addr.is_ipv6() {
1059                    Err(format!("simulated dead IPv6 {addr}"))
1060                } else {
1061                    Ok(addr) // the fake "connection" is just the address that won
1062                }
1063            }
1064        })
1065        .await
1066        .expect("IPv4 fallback wins when IPv6 is dead");
1067
1068        assert_eq!(winner.conn, v4, "the live IPv4 candidate won");
1069        assert_eq!(winner.family, Family::V4);
1070        let tried = attempted.lock().unwrap();
1071        assert!(
1072            tried.contains(&v6),
1073            "the IPv6 candidate was attempted first"
1074        );
1075        assert!(
1076            tried.contains(&v4),
1077            "the IPv4 candidate was attempted as fallback"
1078        );
1079    }
1080
1081    /// IPv6 is the PREFERENCE, not merely first-attempted: with both families live on a dual-stack
1082    /// host, the IPv6 candidate wins the race (IPv4 is only a fallback).
1083    #[tokio::test]
1084    async fn relay_dial_prefers_ipv6_when_both_live() {
1085        let mut candidates = PeerCandidates::new();
1086        let v6: SocketAddr = "[2001:db8::2]:443".parse().unwrap();
1087        let v4: SocketAddr = "203.0.113.8:443".parse().unwrap();
1088        candidates.add(v6, CandidateSource::DnsAAAA);
1089        candidates.add(v4, CandidateSource::DnsA);
1090
1091        let dual = LocalStack::from_flags(true, true);
1092        let calls = AtomicUsize::new(0);
1093        let winner = race_relay_candidates(&dual, &candidates, DialConfig::default(), |addr| {
1094            calls.fetch_add(1, AtomicOrdering::Relaxed);
1095            async move { Ok::<SocketAddr, String>(addr) }
1096        })
1097        .await
1098        .unwrap();
1099
1100        assert_eq!(winner.conn, v6, "IPv6 preferred when both are viable");
1101        assert_eq!(winner.family, Family::V6);
1102    }
1103}