dig_nat/relay.rs
1//! Relay client — the LAST-RESORT transport + the node's persistent reachability channel.
2//!
3//! Relocated + generalized from `dig-node`'s `relay.rs`. Two responsibilities:
4//!
5//! 1. **Persistent reservation** ([`run_relay_connection`]) — a DIG Node behind NAT can't accept
6//! inbound dials, so it holds a CONSTANT registered connection with a publicly-reachable relay
7//! (default [`dig_constants::DIG_RELAY_URL`], override `DIG_RELAY_URL`, opt out with
8//! `DIG_RELAY_URL=off`). This is the reachability channel other peers reach it through and the
9//! rendezvous for relay-coordinated hole-punch.
10//! 2. **Relayed transport** — when every NAT-traversal method fails, peer traffic is tunnelled
11//! THROUGH the relay (RLY-002 `relay_message`). This is the last resort in the traversal order.
12//!
13//! **Graceful-fallback guarantees (baked in):** the reservation loop NEVER blocks startup, NEVER
14//! panics/exits, and NEVER hot-loops error-spam — failures log ONCE per state change (a transition
15//! into `Disconnected`), and every retry sleeps a bounded, capped-exponential backoff. If the relay
16//! is unreachable the node keeps serving indefinitely; the task just keeps retrying in the
17//! background. State is published through [`RelayStatus`] (a cheap atomic snapshot) as one of four
18//! [`RelayState`]s and surfaced verbatim to a `control.relayStatus`-style RPC / `/health`.
19
20use std::collections::{HashMap, HashSet};
21use std::net::{IpAddr, SocketAddr};
22use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::Duration;
25
26use dig_ip::{CandidateSource, DialConfig, LocalStack, PeerCandidates};
27use futures_util::{SinkExt, StreamExt};
28use tokio::net::TcpStream;
29use tokio::sync::mpsc;
30use tokio_tungstenite::tungstenite::Message;
31use tokio_tungstenite::{client_async_tls_with_config, MaybeTlsStream, WebSocketStream};
32
33use crate::wire::{RelayMessage, RelayPeerInfo};
34
35/// Default network id a node registers under (matches dig-gossip `DEFAULT_INTRODUCER_NETWORK_ID`
36/// and dig-node's `DEFAULT_NETWORK_ID`).
37pub const DEFAULT_NETWORK_ID: &str = "DIG_MAINNET";
38
39/// Relay protocol version the node advertises in `Register` (RLY-001).
40pub const RELAY_PROTOCOL_VERSION: u32 = 1;
41
42/// Base reconnect delay (dig-gossip `RelayConfig::reconnect_delay_secs` = 5).
43const BASE_BACKOFF_SECS: u64 = 5;
44/// Cap on the exponential backoff so a long outage doesn't push the retry interval to hours.
45const MAX_BACKOFF_SECS: u64 = 300;
46/// Keepalive ping period (RLY-006; dig-gossip `PING_INTERVAL_SECS` = 30).
47const PING_INTERVAL_SECS: u64 = 30;
48/// How often the held reservation re-pulls the relay peer list (RLY-005 `GetPeers`) over the SAME
49/// persistent socket, so a peer that registers AFTER this node — or one missed on the first pull —
50/// is still discovered without ever reopening the connection (the connect-leg fix).
51const DISCOVERY_INTERVAL_SECS: u64 = 60;
52
53/// Hard cap on the peers retained in the discovered set ([`RelayStatus::known_peers`]).
54///
55/// SECURITY: the relay is an UNTRUSTED intermediary. A hostile/compromised relay can stream an
56/// unbounded flood of `PeerConnected` frames — or a single oversized `Peers` frame — with distinct
57/// fabricated `peer_id`s, so an uncapped set is a memory-exhaustion DoS. 1024 is far more than any
58/// honest relay reports for one network's live reservations (the set is folded into a peer pool that
59/// itself selects a small working subset), yet small enough that the worst case is bounded, cheap
60/// memory. Beyond the cap, further distinct peers are DROPPED rather than grown.
61pub const MAX_KNOWN_PEERS: usize = 1024;
62
63/// Hard cap on the byte length of a single RLY-002 relayed-transport payload (both directions).
64///
65/// SECURITY / backpressure: the relay is UNTRUSTED and a peer reached over relayed transport is the
66/// last-resort TURN path, so an oversized frame is refused rather than buffered — an outbound `send`
67/// larger than this errors, and an inbound frame larger than this is dropped. 1 MiB comfortably
68/// holds a sealed gossip message (NC-1 ciphertext) while bounding the worst-case per-frame memory.
69pub const MAX_RELAY_PAYLOAD: usize = 1 << 20;
70
71/// Bounded inbound capacity for one open [`RelayTunnel`]. A full channel applies backpressure — the
72/// reservation loop `try_send`s inbound relayed bytes and DROPS the frame when the consumer is not
73/// keeping up, so a hostile relay flooding one tunnel cannot exhaust memory (matches the
74/// [`MAX_KNOWN_PEERS`] bounded-set philosophy). The RLY-002 `seq` lets the consumer detect the gap.
75const RELAY_TUNNEL_INBOUND_CAP: usize = 256;
76
77/// Upper bound on concurrently-registered relay tunnels (outbound dial + inbound accept combined)
78/// before the RESPONDER path refuses to create a new inbound circuit.
79///
80/// SECURITY: the relay is UNTRUSTED. When the accept path ([`RelayStatus::enable_accept`]) is on, an
81/// inbound RLY-002 frame from an unknown peer creates a server-role tunnel + surfaces an accept — so
82/// an uncapped accept lets a hostile relay flood distinct fabricated `from` ids to spawn unbounded
83/// tunnels/accept-tasks (a memory/task-exhaustion DoS). Beyond this cap the introduced circuit is
84/// DROPPED rather than accepted. 256 is far more concurrent relayed peers than the last-resort tier
85/// ever legitimately carries, yet bounds the worst case to cheap, bounded memory.
86pub const MAX_RELAY_TUNNELS: usize = 256;
87
88/// Bounded capacity of the inbound-accept channel ([`RelayStatus::enable_accept`]). A full channel
89/// means the consumer is not accepting introduced circuits fast enough; the newest is dropped
90/// (bounded backpressure), never queued unboundedly.
91const INBOUND_ACCEPT_CAP: usize = 64;
92
93/// The mTLS role a locally-registered [`RelayTunnel`] runs — the discriminator that resolves the
94/// GLARE / simultaneous-mutual-dial case (#1536). A relay circuit needs exactly ONE mTLS client + ONE
95/// mTLS server; when two NAT'd peers fall to the relay tier and dial EACH OTHER at the same time (the
96/// common two-NAT'd-peer flywheel case), both open a `Client` tunnel and both send a ClientHello —
97/// each ClientHello would route into the OTHER side's client session (double-ClientHello deadlock).
98/// Tagging the tunnel with its role lets [`route_relayed`](RelayStatus::route_relayed) detect that a
99/// ClientHello arrived on a tunnel where WE are also the client (the glare signature) and apply the
100/// deterministic tie-break instead of feeding it to our doomed client.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum TunnelRole {
103 /// WE initiated the dial — running [`PeerSession::client`](crate::mux::PeerSession::client) over
104 /// this tunnel; inbound frames are the peer's ServerHello / server-side records.
105 Client,
106 /// WE accepted an introduced circuit — running [`PeerSession::server`](crate::mux::PeerSession::server)
107 /// over this tunnel; inbound frames are the dialing peer's client-side records.
108 Server,
109}
110
111/// A registered relayed tunnel: the inbound sink frames are routed into, the mTLS [`TunnelRole`] we
112/// run over it (for the #1536 glare tie-break), and a monotonic `id` distinguishing this registration
113/// from a later one on the SAME peer key. The id matters when the glare tie-break REPLACES our client
114/// tunnel with a server tunnel under the same `from` key: the old client [`RelayTunnel`]'s `Drop`
115/// (fired when its doomed dial fails) must not deregister the NEW server entry, so `close_tunnel` only
116/// removes when the stored id still matches.
117#[derive(Debug)]
118struct TunnelEntry {
119 sink: mpsc::Sender<Vec<u8>>,
120 role: TunnelRole,
121 id: u64,
122}
123
124/// Whether `payload` begins with a TLS handshake record whose first message is a ClientHello — a TLS
125/// record has content-type `0x16` (handshake) at byte 0 and the handshake message type at byte 5
126/// (`0x01` = ClientHello, `0x02` = ServerHello). A rustls client ships its ClientHello flight as the
127/// first `poll_write`, so the first relayed frame from a fresh dialer matches this.
128///
129/// This is the relay layer's DIRECTION discriminator, and it decides the mTLS role in both directions:
130/// a ClientHello means the remote is the circuit's client, so we are its server; anything else is not
131/// the start of an inbound circuit at all. [`RelayStatus::route_relayed`] uses it to tell a peer's
132/// GLARE ClientHello (a competing simultaneous dial) from the ServerHello / app records expected where
133/// we are the client (#1536), and [`RelayStatus::accept_introduced`] uses it to refuse to manufacture
134/// a server-role circuit out of a frame no dialer sent (#1761).
135fn is_tls_client_hello(payload: &[u8]) -> bool {
136 payload.len() >= 6 && payload[0] == 0x16 && payload[5] == 0x01
137}
138
139/// Relay error codes that invalidate THIS node's own reservation, mirroring `dig-relay`'s
140/// `errcode` catalogue. Kept as an explicit list so the two stay conformance-checkable.
141///
142/// - `1 NOT_REGISTERED` — the relay does not consider us registered, so the reservation is gone.
143/// - `4 CAPACITY`, `5 ID_IN_USE`, `6 IDENTITY_MISMATCH`, `7 RATE_LIMITED` — each accompanies a
144/// FAILING `register_ack`: the registration did not happen.
145const FATAL_RELAY_ERROR_CODES: [u32; 5] = [1, 4, 5, 6, 7];
146
147/// Whether a relay `Error` frame means the RESERVATION is invalid (end the loop, back off and
148/// re-register) rather than a single request having failed (log it; keep serving).
149///
150/// The relay reports both kinds on one channel, and the per-request kind is the COMMON one:
151/// `3 PEER_NOT_FOUND` simply means the peer we tried to reach is no longer on this relay, which
152/// happens constantly on a live network and says nothing about our own registration. Treating it as
153/// fatal cost the node its reservation on every failed dial (dig_ecosystem #1932).
154///
155/// An UNKNOWN (future) code is deliberately treated as NON-fatal. Guessing wrong in that direction
156/// costs one logged line; guessing wrong the other way would let a newly-introduced code drop every
157/// node on the network off its relay at once.
158pub fn relay_error_is_fatal(code: u32) -> bool {
159 FATAL_RELAY_ERROR_CODES.contains(&code)
160}
161
162/// Compute the next reconnect backoff: capped exponential in the number of consecutive failures.
163/// `failures == 0` → base; doubles each failure up to [`MAX_BACKOFF_SECS`]. Pure → unit-tested.
164pub fn backoff_secs(consecutive_failures: u32) -> u64 {
165 backoff_secs_with(consecutive_failures, BASE_BACKOFF_SECS, MAX_BACKOFF_SECS)
166}
167
168/// Capped-exponential backoff with an explicit base + cap. Always returns a value in `[base, cap]`
169/// — never zero — so a failing connect can never busy-loop.
170fn backoff_secs_with(consecutive_failures: u32, base: u64, cap: u64) -> u64 {
171 let shifted = base.checked_shl(consecutive_failures).unwrap_or(cap);
172 shifted.clamp(base, cap)
173}
174
175/// Backoff schedule for the reconnect loop — production defaults, or fast values for tests.
176#[derive(Debug, Clone, Copy)]
177pub struct Backoff {
178 /// First-retry delay (seconds).
179 pub base_secs: u64,
180 /// Upper bound on the delay (seconds).
181 pub cap_secs: u64,
182}
183
184impl Default for Backoff {
185 fn default() -> Self {
186 Backoff {
187 base_secs: BASE_BACKOFF_SECS,
188 cap_secs: MAX_BACKOFF_SECS,
189 }
190 }
191}
192
193/// The four observable states of the relay reservation, surfaced verbatim (lowercase) as the
194/// `state` field of a `control.relayStatus`-style RPC.
195///
196/// - `Disabled` — reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
197/// - `Connecting` — actively dialing/registering.
198/// - `Connected` — a reservation is held (`RegisterAck{success:true}` arrived); reachable to peers.
199/// - `Disconnected` — not connected; backing off + will retry. The graceful-fallback resting state.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum RelayState {
202 /// Reservation OFF (`DIG_RELAY_URL=off`); no task runs, no attempts made.
203 Disabled,
204 /// Actively dialing/registering (initial attempt or a reconnect in flight).
205 Connecting,
206 /// A reservation is held (`RegisterAck{success:true}` arrived); reachable to NAT'd peers.
207 Connected,
208 /// Not connected; backing off + will retry. The graceful-fallback resting state.
209 Disconnected,
210}
211
212impl RelayState {
213 /// The stable lowercase wire string for the RPC `state` field.
214 pub fn as_str(self) -> &'static str {
215 match self {
216 RelayState::Disabled => "disabled",
217 RelayState::Connecting => "connecting",
218 RelayState::Connected => "connected",
219 RelayState::Disconnected => "disconnected",
220 }
221 }
222
223 fn to_u8(self) -> u8 {
224 match self {
225 RelayState::Disabled => 0,
226 RelayState::Connecting => 1,
227 RelayState::Connected => 2,
228 RelayState::Disconnected => 3,
229 }
230 }
231
232 fn from_u8(v: u8) -> Self {
233 match v {
234 0 => RelayState::Disabled,
235 1 => RelayState::Connecting,
236 2 => RelayState::Connected,
237 _ => RelayState::Disconnected,
238 }
239 }
240}
241
242/// The peers discovered over the live reservation socket, in insertion order with O(1) dedup +
243/// membership by `peer_id`, bounded to [`MAX_KNOWN_PEERS`].
244///
245/// `order` preserves discovery order so [`RelayStatus::known_peers`] returns a stable sequence;
246/// `ids` mirrors `order`'s `peer_id`s so dedup and removal are O(1) instead of a linear scan (the
247/// old `iter().any(...)` was O(n²) over a flood). The two are kept in lockstep — every mutation
248/// touches both.
249#[derive(Debug, Default)]
250struct DiscoveredPeers {
251 order: Vec<RelayPeerInfo>,
252 ids: HashSet<String>,
253}
254
255impl DiscoveredPeers {
256 /// Insert `peer` unless already present or the set is full. Returns nothing — a full set simply
257 /// drops the newcomer (the untrusted-relay flood defense).
258 fn insert(&mut self, peer: RelayPeerInfo) {
259 if self.order.len() >= MAX_KNOWN_PEERS {
260 return;
261 }
262 if self.ids.insert(peer.peer_id.clone()) {
263 self.order.push(peer);
264 }
265 }
266
267 /// Remove the peer with this `peer_id`, if present.
268 fn remove(&mut self, peer_id: &str) {
269 if self.ids.remove(peer_id) {
270 self.order.retain(|p| p.peer_id != peer_id);
271 }
272 }
273
274 /// Replace the whole set from a `Peers` frame, deduped + truncated to the cap.
275 fn replace(&mut self, peers: Vec<RelayPeerInfo>) {
276 self.order.clear();
277 self.ids.clear();
278 for peer in peers {
279 self.insert(peer);
280 }
281 }
282
283 fn clear(&mut self) {
284 self.order.clear();
285 self.ids.clear();
286 }
287}
288
289/// Live relay-connection status, shared (via `Arc`) between the connection task and an RPC handler.
290/// Cheap atomic reads. State setters do STATE-CHANGE-ONLY logging so a long outage never hot-loops
291/// identical error lines.
292#[derive(Debug)]
293pub struct RelayStatus {
294 state: AtomicU8,
295 reconnect_attempts: AtomicU32,
296 connected_peers: AtomicU64,
297 last_error: Mutex<Option<String>>,
298 /// Peers learned over the LIVE reservation socket — the relay's `GetPeers` response (RLY-005)
299 /// plus `PeerConnected`/`PeerDisconnected` pushes. This is the discovery output of the persistent
300 /// reservation: a consumer (dig-gossip's pool/address book) reads it instead of reopening an
301 /// ephemeral socket per pass. Keyed by `peer_id` (deduped); bounded to [`MAX_KNOWN_PEERS`] so an
302 /// untrusted relay can't exhaust memory; cleared on every reconnect so a stale list is never
303 /// served across a drop.
304 known_peers: Mutex<DiscoveredPeers>,
305 /// Sink that injects an outbound [`RelayMessage`] into the LIVE reservation socket's write half.
306 /// `Some` only while a session is held (set by `connect_once`, cleared on every drop) — this is
307 /// what lets a [`RelayTunnel`] reuse the ONE persistent reservation socket for RLY-002 relayed
308 /// transport instead of opening a second connection.
309 outbound: Mutex<Option<mpsc::UnboundedSender<RelayMessage>>>,
310 /// This node's own `peer_id` (hex), stamped as `from` on every RLY-002 frame the tunnels send.
311 /// Set when a session registers; needed because a tunnel is opened from the shared status handle.
312 local_peer_id: Mutex<Option<String>>,
313 /// The network id this reservation registered under. Echoed onto an inbound accepted tunnel (the
314 /// RLY-002 frame itself does not carry it). Set alongside [`local_peer_id`] when a session registers.
315 local_network_id: Mutex<Option<String>>,
316 /// Sink that surfaces an INTRODUCED inbound circuit — a frame from a peer with NO open outbound
317 /// tunnel — as a server-role [`RelayTunnel`] for a consumer to accept + serve
318 /// ([`crate::accept::RelayAcceptor`]). `None` (default) = the original untrusted-relay behavior:
319 /// drop an unknown-peer frame. `Some` once a consumer calls [`RelayStatus::enable_accept`].
320 ///
321 /// This is the RESPONDER counterpart to [`open_tunnel`](Self::open_tunnel): a relay circuit needs
322 /// exactly ONE mTLS client + ONE mTLS server. The DIALER calls `open_tunnel` and runs
323 /// `PeerSession::client`; the reservation-HOLDER that RECEIVES the introduced circuit accepts here
324 /// and runs `PeerSession::server`. Without this path both ends acted as TLS client and the
325 /// handshake deadlocked (`got ClientHello when expecting ServerHello`, #1536).
326 inbound_accept: Mutex<Option<mpsc::Sender<RelayTunnel>>>,
327 /// Open relayed-transport tunnels, keyed by the REMOTE peer's `peer_id` (hex). An inbound RLY-002
328 /// `relay_message` from a peer is routed to its tunnel's inbound channel; a frame from a peer with
329 /// no open tunnel is dropped (the untrusted-relay default). Entries are removed on tunnel drop.
330 /// Each entry carries the mTLS [`TunnelRole`] we run over it so `route_relayed` can resolve the
331 /// #1536 simultaneous-mutual-dial glare deterministically.
332 tunnels: Mutex<HashMap<String, TunnelEntry>>,
333 /// Monotonic per-node sequence number stamped on outbound RLY-002 frames (ordering/dedup).
334 relay_seq: AtomicU64,
335 /// Monotonic id assigned to each tunnel registration so a stale [`RelayTunnel`]'s `Drop` never
336 /// deregisters a NEWER entry under the same peer key (see [`TunnelEntry::id`]; #1536 glare replace).
337 next_tunnel_id: AtomicU64,
338 /// Supplies the aggregated DHT-record view answered to RLY-009 `get_dht_records`
339 /// (dig_ecosystem #1935). `None` (the default) means this node answers NOTHING — a node that
340 /// has not opted in is indistinguishable on the wire from a pre-RLY-009 one, which is the safe
341 /// default for an observability feature.
342 ///
343 /// A closure rather than a data field because the records live in the DHT layer, which sits
344 /// ABOVE this crate in the hierarchy: `dig-dht` depends on `dig-nat`, never the reverse. The
345 /// consumer registers a reader; this crate only forwards the answer.
346 dht_records: Mutex<DhtRecordsHook>,
347}
348
349/// Holds the optional RLY-009 reader. A closure has no `Debug`, and `RelayStatus` derives it, so the
350/// hook is wrapped rather than making every field's diagnostics hand-written.
351#[derive(Default)]
352struct DhtRecordsHook(Option<Arc<DhtRecordsProvider>>);
353
354impl std::fmt::Debug for DhtRecordsHook {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 // Whether a reader is registered is the only useful diagnostic; the closure itself is opaque.
357 f.write_str(if self.0.is_some() {
358 "DhtRecordsHook(registered)"
359 } else {
360 "DhtRecordsHook(none)"
361 })
362 }
363}
364
365/// Reads this node's aggregated DHT provider records, bounded by the caller's `max_keys`, for the
366/// RLY-009 answer. Registered by the consumer via [`RelayStatus::set_dht_records_provider`].
367pub type DhtRecordsProvider = dyn Fn(usize) -> DhtRecordsAnswer + Send + Sync;
368
369/// What a node reports for RLY-009 — the wire payload, before it becomes a
370/// [`RelayMessage::DhtRecords`].
371#[derive(Debug, Clone, Default, PartialEq, Eq)]
372pub struct DhtRecordsAnswer {
373 /// Content keys and their live provider COUNTS. Never provider identities.
374 pub records: Vec<crate::wire::DhtRecordEntry>,
375 /// Keys with a live provider before `max_keys` was applied.
376 pub total_keys: usize,
377 /// Whether `max_keys` dropped entries.
378 pub truncated: bool,
379}
380
381impl Default for RelayStatus {
382 fn default() -> Self {
383 RelayStatus {
384 state: AtomicU8::new(RelayState::Disconnected.to_u8()),
385 reconnect_attempts: AtomicU32::new(0),
386 connected_peers: AtomicU64::new(0),
387 last_error: Mutex::new(None),
388 known_peers: Mutex::new(DiscoveredPeers::default()),
389 outbound: Mutex::new(None),
390 local_peer_id: Mutex::new(None),
391 local_network_id: Mutex::new(None),
392 inbound_accept: Mutex::new(None),
393 tunnels: Mutex::new(HashMap::new()),
394 relay_seq: AtomicU64::new(0),
395 next_tunnel_id: AtomicU64::new(0),
396 dht_records: Mutex::new(DhtRecordsHook::default()),
397 }
398 }
399}
400
401impl RelayStatus {
402 /// A fresh status (resting `Disconnected` until the task runs / the relay is reached).
403 pub fn new() -> Arc<Self> {
404 Arc::new(RelayStatus::default())
405 }
406
407 /// Read the current state.
408 pub fn state(&self) -> RelayState {
409 RelayState::from_u8(self.state.load(Ordering::Relaxed))
410 }
411
412 /// Transition to `next`, returning `true` IFF the state actually changed. Callers use the return
413 /// to log ONCE per transition (no hot-loop spam).
414 fn transition_to(&self, next: RelayState) -> bool {
415 let prev = self.state.swap(next.to_u8(), Ordering::Relaxed);
416 prev != next.to_u8()
417 }
418
419 /// Enter `Disabled` (reservation off). Idempotent; logs only on the first entry.
420 pub fn set_disabled(&self) {
421 if self.transition_to(RelayState::Disabled) {
422 tracing::info!("relay reservation disabled (DIG_RELAY_URL=off)");
423 }
424 }
425
426 /// Enter `Connecting`. Logs only on the transition (so reconnect attempts don't spam).
427 pub fn set_connecting(&self) {
428 if self.transition_to(RelayState::Connecting) {
429 tracing::debug!("relay connecting");
430 }
431 }
432
433 /// Mark `Connected` (clears the last error, resets the attempt counter). Logs recovery once.
434 pub fn set_connected(&self, connected_peers: u64) {
435 self.connected_peers
436 .store(connected_peers, Ordering::Relaxed);
437 self.reconnect_attempts.store(0, Ordering::Relaxed);
438 *self.last_error.lock().unwrap() = None;
439 if self.transition_to(RelayState::Connected) {
440 tracing::info!(connected_peers, "relay reservation established");
441 }
442 }
443
444 /// Mark `Disconnected` with an optional error and bump the attempt counter. Logs the failure
445 /// ONLY on the transition into `Disconnected` (the first drop); subsequent failed retries while
446 /// already `Disconnected` update the error/counter SILENTLY.
447 pub fn set_disconnected(&self, error: Option<String>) {
448 self.reconnect_attempts.fetch_add(1, Ordering::Relaxed);
449 if let Some(e) = &error {
450 *self.last_error.lock().unwrap() = Some(e.clone());
451 }
452 let changed = self.transition_to(RelayState::Disconnected);
453 if changed {
454 match &error {
455 Some(e) => tracing::warn!(
456 error = %e,
457 "relay reservation lost — node still serving; retrying in background"
458 ),
459 None => tracing::info!("relay reservation closed — retrying in background"),
460 }
461 }
462 }
463
464 /// Whether a relay session is currently held.
465 pub fn is_connected(&self) -> bool {
466 self.state() == RelayState::Connected
467 }
468
469 /// The current reconnect-attempt count (for tests / RPC).
470 pub fn reconnect_attempts(&self) -> u32 {
471 self.reconnect_attempts.load(Ordering::Relaxed)
472 }
473
474 /// Snapshot of the peers discovered over the live reservation socket (RLY-005 `Peers` +
475 /// `PeerConnected` pushes, minus `PeerDisconnected`). The consumer folds these into its address
476 /// book / pool. Returns a clone so the caller holds no lock.
477 pub fn known_peers(&self) -> Vec<RelayPeerInfo> {
478 self.known_peers.lock().unwrap().order.clone()
479 }
480
481 /// Count of peers currently discovered over the live reservation socket.
482 pub fn known_peer_count(&self) -> usize {
483 self.known_peers.lock().unwrap().order.len()
484 }
485
486 /// Replace the discovered-peer set with a `GetPeers` response (RLY-005 `Peers`), deduped and
487 /// truncated to [`MAX_KNOWN_PEERS`] (an untrusted relay could send an oversized frame).
488 fn replace_known_peers(&self, peers: Vec<RelayPeerInfo>) {
489 self.known_peers.lock().unwrap().replace(peers);
490 }
491
492 /// Fold in a relay-pushed `PeerConnected` notice, deduped by `peer_id`; dropped once the set is
493 /// full ([`MAX_KNOWN_PEERS`]) so a flood can't exhaust memory.
494 fn add_known_peer(&self, peer: RelayPeerInfo) {
495 self.known_peers.lock().unwrap().insert(peer);
496 }
497
498 /// Drop a peer on a relay-pushed `PeerDisconnected` notice.
499 fn remove_known_peer(&self, peer_id: &str) {
500 self.known_peers.lock().unwrap().remove(peer_id);
501 }
502
503 /// Clear the discovered-peer set (on every reconnect — the list is per-session).
504 fn clear_known_peers(&self) {
505 self.known_peers.lock().unwrap().clear();
506 }
507
508 // -- RLY-002 relayed transport (the tier-6 TURN fallback) ------------------------------------
509 //
510 // A relayed tunnel reuses the ONE persistent reservation socket: outbound frames go through
511 // `outbound` (drained by the reservation loop's write half), inbound `relay_message` frames are
512 // routed by `from` peer_id to the matching tunnel. Available only while the reservation is held.
513
514 /// Install the live session's outbound sink + this node's `peer_id` + the registered `network_id`.
515 /// Called by `connect_once` once registered; cleared by [`clear_transport`](Self::clear_transport)
516 /// on every drop.
517 fn set_transport(
518 &self,
519 peer_id: &str,
520 network_id: &str,
521 outbound: mpsc::UnboundedSender<RelayMessage>,
522 ) {
523 *self.local_peer_id.lock().unwrap() = Some(peer_id.to_string());
524 *self.local_network_id.lock().unwrap() = Some(network_id.to_string());
525 *self.outbound.lock().unwrap() = Some(outbound);
526 }
527
528 /// Register the reader that answers RLY-009 `get_dht_records` (dig_ecosystem #1935).
529 ///
530 /// Until this is called the node answers nothing, which is on the wire indistinguishable from a
531 /// pre-RLY-009 node — the correct default for an observability feature that publishes what this
532 /// node knows about the network.
533 ///
534 /// The reader receives the relay's requested `max_keys` and MUST honour it: the provider store is
535 /// attacker-influenced (any peer can `add_provider`), so an unbounded answer would let a Sybil
536 /// dictate the frame size on a socket this node depends on for reachability.
537 pub fn set_dht_records_provider<F>(&self, reader: F)
538 where
539 F: Fn(usize) -> DhtRecordsAnswer + Send + Sync + 'static,
540 {
541 self.dht_records.lock().unwrap().0 = Some(Arc::new(reader));
542 }
543
544 /// The registered RLY-009 reader, if the consumer opted in.
545 fn dht_records_provider(&self) -> Option<Arc<DhtRecordsProvider>> {
546 self.dht_records.lock().unwrap().0.clone()
547 }
548
549 /// Enable the RESPONDER (accept) path and return the receiver of INTRODUCED inbound circuits.
550 ///
551 /// A relayed connection needs one mTLS client + one mTLS server. The dialer opens a tunnel and
552 /// runs the client; the reservation-HOLDER calls this once at startup and, for every inbound
553 /// frame from a peer it has no open outbound tunnel to, receives a server-role [`RelayTunnel`]
554 /// here to hand to a [`crate::accept::RelayAcceptor`] (which runs `PeerSession::server`). Until a
555 /// consumer calls this, unknown-peer frames are DROPPED (the untrusted-relay default), so the
556 /// accept path is strictly opt-in. The channel is bounded ([`INBOUND_ACCEPT_CAP`]).
557 pub fn enable_accept(&self) -> mpsc::Receiver<RelayTunnel> {
558 let (tx, rx) = mpsc::channel(INBOUND_ACCEPT_CAP);
559 *self.inbound_accept.lock().unwrap() = Some(tx);
560 rx
561 }
562
563 /// Tear down the transport on session drop: drop the outbound sink (so tunnel sends fail fast)
564 /// and close every open tunnel's inbound channel (so a blocked `recv` wakes with `None`).
565 fn clear_transport(&self) {
566 *self.outbound.lock().unwrap() = None;
567 self.tunnels.lock().unwrap().clear();
568 }
569
570 /// Whether a relayed tunnel can currently be opened — a reservation is held AND its outbound sink
571 /// is live. The tier-6 [`RelayedTransport`](crate::method::relayed::RelayedTransport) gates on this.
572 pub fn relay_transport_ready(&self) -> bool {
573 self.is_connected() && self.outbound.lock().unwrap().is_some()
574 }
575
576 /// Open an RLY-002 relayed-transport tunnel to `target_peer` (hex `peer_id`) over the held
577 /// reservation socket — the traversal ladder's FINAL tier when a pair can neither direct-dial nor
578 /// hole-punch. The returned [`RelayTunnel`] sends/receives opaque payloads that the relay forwards
579 /// A→relay→B; per NC-1 the payload is END-TO-END SEALED to the recipient so the relay forwards
580 /// ciphertext only. `Err` if no reservation is held. Dropping the tunnel deregisters it.
581 pub fn open_tunnel(
582 self: &Arc<Self>,
583 target_peer: &str,
584 network_id: &str,
585 ) -> Result<RelayTunnel, String> {
586 if !self.relay_transport_ready() {
587 return Err("relay reservation not connected — cannot open relayed tunnel".into());
588 }
589 // Self / SPKI-collision guard: a relayed circuit to our OWN peer_id has no lower/higher end
590 // for the glare tie-break, so it could never converge to one-client-one-server — refuse it
591 // (#1536).
592 let local = self
593 .local_peer_id
594 .lock()
595 .unwrap()
596 .clone()
597 .unwrap_or_default();
598 if !local.is_empty() && local == target_peer {
599 return Err("refusing relayed self-dial (target == local peer_id)".into());
600 }
601 // NON-CLOBBER (#1536): if a circuit to this peer already exists — because an introduced
602 // ClientHello made us its SERVER before this dial ran (a timing-ordered glare) — do NOT open a
603 // second, conflicting circuit under the same key. The existing circuit IS the connection; a
604 // duplicate would orphan one role and leave two mTLS sessions racing to one peer. Checked +
605 // inserted under ONE lock so a concurrent `route_relayed` cannot slip a role in between.
606 let mut tunnels = self.tunnels.lock().unwrap();
607 if tunnels.contains_key(target_peer) {
608 return Err("existing relay circuit to peer — not opening a duplicate".into());
609 }
610 // A dialer runs the mTLS client over the tunnel it opens.
611 Ok(self.insert_entry(&mut tunnels, target_peer, network_id, TunnelRole::Client))
612 }
613
614 /// Register a tunnel routing entry for `target_peer` and build its [`RelayTunnel`], overwriting any
615 /// existing entry under the key. Test-only (the `open_server_tunnel` + flood-cap tests use it); the
616 /// production paths ([`open_tunnel`](Self::open_tunnel) + [`accept_introduced`](Self::accept_introduced))
617 /// go through non-clobber checks first.
618 #[cfg(test)]
619 fn register_tunnel(
620 self: &Arc<Self>,
621 target_peer: &str,
622 network_id: &str,
623 role: TunnelRole,
624 ) -> RelayTunnel {
625 let mut tunnels = self.tunnels.lock().unwrap();
626 self.insert_entry(&mut tunnels, target_peer, network_id, role)
627 }
628
629 /// Build a fresh [`RelayTunnel`] for `target_peer` with `role` and insert its entry into the
630 /// already-locked `tunnels` map (assigning a monotonic id so a stale `Drop` never evicts a newer
631 /// registration). The caller holds the lock, so the check-then-insert is atomic — the #1536
632 /// non-clobber + role-race defense.
633 fn insert_entry(
634 self: &Arc<Self>,
635 tunnels: &mut HashMap<String, TunnelEntry>,
636 target_peer: &str,
637 network_id: &str,
638 role: TunnelRole,
639 ) -> RelayTunnel {
640 let (tx, rx) = mpsc::channel(RELAY_TUNNEL_INBOUND_CAP);
641 let id = self.next_tunnel_id.fetch_add(1, Ordering::Relaxed);
642 tunnels.insert(target_peer.to_string(), TunnelEntry { sink: tx, role, id });
643 RelayTunnel {
644 target: target_peer.to_string(),
645 network_id: network_id.to_string(),
646 status: Arc::clone(self),
647 inbound: rx,
648 id,
649 }
650 }
651
652 /// Route one inbound RLY-002 `relay_message` to its tunnel by `from` peer_id. Oversized payloads
653 /// are dropped (size cap); a frame from a peer with no open tunnel becomes an introduced circuit
654 /// ONLY when it is a dialer's opening ClientHello (#1761 — see
655 /// [`accept_introduced`](Self::accept_introduced)), and is otherwise dropped, as it is when the
656 /// responder path is off or a flood cap is hit; a full inbound channel drops the frame
657 /// (backpressure). Returns silently in every drop case (untrusted relay).
658 ///
659 /// GLARE (#1536): when a ClientHello arrives on a tunnel where WE are ALSO the client — the peer
660 /// dialed us at the same time we dialed it — a deterministic tie-break makes exactly ONE side the
661 /// server: the numerically-LOWER `peer_id` becomes the server. Both ends compute the same rule, so
662 /// a crossed pair converges to one-client-one-server under ANY frame ordering with no retry loop.
663 /// The TIMING-ordered variant (a peer's ClientHello arrives BEFORE our own dial registers) cannot
664 /// produce a conflicting second circuit either: [`open_tunnel`](Self::open_tunnel) is non-clobber,
665 /// so once we serve a peer our later dial to it is refused. The per-frame role LOOKUP + any
666 /// same-frame yield (client-tunnel removal) happen under a single lock acquisition; the server
667 /// registration in [`accept_introduced`](Self::accept_introduced) re-acquires and re-checks
668 /// (non-clobber), so a dial racing between the two regions is abandoned, never a double-session.
669 fn route_relayed(self: &Arc<Self>, from: &str, payload: Vec<u8>) {
670 if payload.len() > MAX_RELAY_PAYLOAD {
671 tracing::debug!(
672 from,
673 len = payload.len(),
674 "dropping oversized relayed frame"
675 );
676 return;
677 }
678 let local = self
679 .local_peer_id
680 .lock()
681 .unwrap()
682 .clone()
683 .unwrap_or_default();
684 // Self / SPKI-collision guard: a frame stamped with our OWN id can never be a real remote peer
685 // (and the tie-break has no lower/higher end for it) — drop it rather than risk a no-server
686 // hang (#1536).
687 if !local.is_empty() && local == from {
688 tracing::debug!("dropping relayed frame stamped with our own peer_id (self/collision)");
689 return;
690 }
691
692 // Decide the action for this frame under ONE tunnels-lock so a concurrent `open_tunnel` cannot
693 // race the role assignment.
694 enum Route {
695 /// Deliver into an existing tunnel's inbound sink.
696 Deliver(mpsc::Sender<Vec<u8>>),
697 /// Drop the frame (we retain our client role, or cannot serve).
698 Ignore,
699 /// Accept as an introduced server-role circuit.
700 Accept,
701 }
702 let route = {
703 let mut tunnels = self.tunnels.lock().unwrap();
704 match tunnels.get(from).map(|e| (e.sink.clone(), e.role, e.id)) {
705 // We are the SERVER for this peer — every frame is the client's; route it.
706 Some((sink, TunnelRole::Server, _)) => Route::Deliver(sink),
707 // We are the CLIENT. A ServerHello / app record is the expected response; a ClientHello
708 // means the peer dialed us at the same time (GLARE).
709 Some((sink, TunnelRole::Client, id)) => {
710 if !is_tls_client_hello(&payload) {
711 Route::Deliver(sink)
712 } else if local.as_str() > from {
713 // Higher id → WE keep the client role; ignore the peer's competing ClientHello
714 // (the lower-id peer yields to server and answers with a ServerHello).
715 tracing::debug!(
716 from,
717 "relay glare — retaining client role (peer yields to server)"
718 );
719 Route::Ignore
720 } else if self.inbound_accept.lock().unwrap().is_none() {
721 // Lower id → should be server, but no responder path is enabled; cannot serve,
722 // so keep the (doomed) client tunnel and drop rather than tear it down.
723 tracing::debug!(
724 from,
725 "relay glare — should be server but accept path off; dropping"
726 );
727 Route::Ignore
728 } else {
729 // Lower id → yield to the server role: drop our client tunnel (only if it is
730 // still ours) and accept the peer's circuit as server below.
731 if tunnels.get(from).map(|e| e.id) == Some(id) {
732 tunnels.remove(from);
733 }
734 tracing::debug!(from, "relay glare — yielding to server role");
735 Route::Accept
736 }
737 }
738 // No circuit under this key — a candidate INTRODUCED circuit (a peer dialing us over
739 // the relay). Whether it really is one is decided by the frame's direction in
740 // `accept_introduced`, which admits only a dialer's opening ClientHello (#1761).
741 None => Route::Accept,
742 }
743 };
744
745 match route {
746 Route::Deliver(sink) => {
747 if sink.try_send(payload).is_err() {
748 tracing::debug!(from, "relayed tunnel inbound full/closed — frame dropped");
749 }
750 }
751 Route::Ignore => {}
752 Route::Accept => self.accept_introduced(from, payload),
753 }
754 }
755
756 /// Accept an INTRODUCED inbound circuit from `from` as a server-role tunnel and surface it to the
757 /// consumer's [`RelayAcceptor`](crate::accept::RelayAcceptor) — the RESPONDER path. Gated on: the
758 /// frame actually being a dialer's OPENING HANDSHAKE (below), the responder path being enabled
759 /// ([`enable_accept`](Self::enable_accept)), NON-CLOBBER (a circuit already under this key is kept,
760 /// never replaced by a racing registration — the #1536 double-session defense), and the flood cap
761 /// ([`MAX_RELAY_TUNNELS`]). The opening frame (the dialer's ClientHello) is delivered into the fresh
762 /// tunnel so the server handshake sees it; a full accept channel drops the newest circuit (bounded
763 /// backpressure).
764 ///
765 /// This is the ONE place a server-role circuit is created, so the mTLS role of a relayed circuit is
766 /// decided HERE and only here, from the circuit's own direction — never from what the accept path
767 /// happens to be handed.
768 fn accept_introduced(self: &Arc<Self>, from: &str, payload: Vec<u8>) {
769 // #1761: ONLY a client's opening handshake may open an inbound circuit. A relayed frame from a
770 // peer we hold no tunnel to is otherwise NOT the start of a circuit — it is a frame belonging to
771 // a circuit that no longer exists here, or one that was never ours. The whole class must be
772 // dropped, not just the observed instance: a peer's ServerHello or application record arriving
773 // after `fast_connect` released the per-peer tunnel on a relayed→direct promotion, a frame that
774 // outlived a timed-out or torn-down circuit, and any garbage an untrusted relay injects. Accepting
775 // any of them stands up a TLS SERVER against a peer that is itself a server, which is the live
776 // `got ServerHello when expecting ClientHello` / `UnexpectedMessage` deadlock — and it also let a
777 // single arbitrary byte cost a tunnel slot plus an accept-task.
778 if !is_tls_client_hello(&payload) {
779 // The relay-supplied `from` is deliberately NOT logged: it is an untrusted, unbounded
780 // peer-controlled string, and this line is reachable by any frame a hostile relay cares to
781 // send. The frame length is the only diagnostic that cannot carry attacker text.
782 tracing::debug!(
783 len = payload.len(),
784 "dropping a relayed frame that is not a dialer's opening handshake — no circuit is open \
785 for this peer"
786 );
787 return;
788 }
789 let Some(accept_tx) = self.inbound_accept.lock().unwrap().clone() else {
790 return;
791 };
792 let network_id = self
793 .local_network_id
794 .lock()
795 .unwrap()
796 .clone()
797 .unwrap_or_default();
798 let tunnel = {
799 let mut tunnels = self.tunnels.lock().unwrap();
800 if tunnels.contains_key(from) {
801 // A circuit to this peer already exists (a racing dial claimed the key) — do NOT
802 // clobber it into a conflicting second session.
803 return;
804 }
805 if tunnels.len() >= MAX_RELAY_TUNNELS {
806 tracing::debug!(
807 from,
808 "inbound relay accept cap reached — dropping introduced circuit"
809 );
810 return;
811 }
812 self.insert_entry(&mut tunnels, from, &network_id, TunnelRole::Server)
813 };
814 // Deliver the opening frame so the server-side handshake sees the ClientHello.
815 if let Some(sink) = self
816 .tunnels
817 .lock()
818 .unwrap()
819 .get(from)
820 .map(|e| e.sink.clone())
821 {
822 let _ = sink.try_send(payload);
823 }
824 // Hand the server-role tunnel to the consumer to run `PeerSession::server` over. A full/closed
825 // accept channel drops the tunnel here — its `Drop` deregisters the routing.
826 if accept_tx.try_send(tunnel).is_err() {
827 tracing::debug!(
828 from,
829 "inbound accept channel full/closed — dropping introduced circuit"
830 );
831 }
832 }
833
834 /// Remove a tunnel's routing entry (called on [`RelayTunnel`] drop) — but ONLY when the stored
835 /// entry is still THIS registration (`id` matches). A glare tie-break can replace our client
836 /// tunnel with a server tunnel under the same peer key (#1536); the old client tunnel's `Drop`
837 /// must not then evict the newer server entry.
838 fn close_tunnel(&self, target_peer: &str, id: u64) {
839 let mut tunnels = self.tunnels.lock().unwrap();
840 if tunnels.get(target_peer).map(|e| e.id) == Some(id) {
841 tunnels.remove(target_peer);
842 }
843 }
844
845 /// Whether a relayed tunnel to `target_peer` is currently registered — the test hook fast-connect
846 /// uses to assert the per-peer tunnel was released (dropped) after a relayed→direct promotion,
847 /// while the reservation itself stays held.
848 #[cfg(test)]
849 pub(crate) fn open_tunnel_exists(&self, target_peer: &str) -> bool {
850 self.tunnels.lock().unwrap().contains_key(target_peer)
851 }
852
853 /// Test-only: register a SERVER-role tunnel to `target_peer` directly, for tests that drive an mTLS
854 /// SERVER over a hand-wired relay tunnel (the production server path is [`enable_accept`] +
855 /// [`route_relayed`]'s accept branch). A server-role tunnel routes an incoming ClientHello straight
856 /// through instead of treating it as the #1536 glare signal, so these tests mirror a real server
857 /// receiving a dialer's ClientHello.
858 #[cfg(test)]
859 pub(crate) fn open_server_tunnel(
860 self: &Arc<Self>,
861 target_peer: &str,
862 network_id: &str,
863 ) -> RelayTunnel {
864 self.register_tunnel(target_peer, network_id, TunnelRole::Server)
865 }
866
867 /// A JSON snapshot for a `control.relayStatus`-style RPC. `state` is the canonical truth;
868 /// `connected` is a convenience boolean (== `state == connected`).
869 pub fn snapshot_json(&self, endpoint: &str, peer_id: &str) -> serde_json::Value {
870 let state = self.state();
871 serde_json::json!({
872 "state": state.as_str(),
873 "connected": state == RelayState::Connected,
874 "endpoint": endpoint,
875 "peer_id": peer_id,
876 "reconnect_attempts": self.reconnect_attempts.load(Ordering::Relaxed),
877 "connected_peers": self.connected_peers.load(Ordering::Relaxed),
878 "last_error": *self.last_error.lock().unwrap(),
879 })
880 }
881}
882
883/// A live RLY-002 relayed-transport tunnel to one peer, multiplexed over the node's persistent relay
884/// reservation socket (the tier-6 TURN fallback). Writes are framed as RLY-002 `relay_message` to the
885/// target and forwarded A→relay→B; reads are the payloads the relay forwards back from that peer.
886///
887/// Per NC-1 the payload MUST be END-TO-END SEALED to the recipient's key by the caller — the relay is
888/// an untrusted forwarder that sees only ciphertext. Dropping the tunnel deregisters its routing.
889pub struct RelayTunnel {
890 /// The remote peer's `peer_id` (hex) — the RLY-002 `to`, and the routing key for inbound frames.
891 target: String,
892 /// The network the tunnel is scoped to (echoed for the consumer; relay routes by peer_id).
893 network_id: String,
894 /// Shared status handle — provides the live outbound sink, this node's `peer_id`, and the seq.
895 status: Arc<RelayStatus>,
896 /// Inbound payloads the relay forwarded from `target`, in arrival order (bounded — see
897 /// [`RELAY_TUNNEL_INBOUND_CAP`]).
898 inbound: mpsc::Receiver<Vec<u8>>,
899 /// This registration's monotonic id — so `Drop` only deregisters when the map still holds THIS
900 /// entry (a glare replace may have superseded it under the same key; #1536).
901 id: u64,
902}
903
904impl RelayTunnel {
905 /// The remote peer this tunnel forwards to/from (hex `peer_id`).
906 pub fn target(&self) -> &str {
907 &self.target
908 }
909
910 /// The network the tunnel is scoped to.
911 pub fn network_id(&self) -> &str {
912 &self.network_id
913 }
914
915 /// Send `payload` to the target peer through the relay (RLY-002 `relay_message`). `payload` MUST
916 /// already be sealed to the recipient (NC-1). `Err` if the reservation dropped (send after the
917 /// session closed) or `payload` exceeds [`MAX_RELAY_PAYLOAD`].
918 pub fn send(&self, payload: Vec<u8>) -> Result<(), String> {
919 if payload.len() > MAX_RELAY_PAYLOAD {
920 return Err(format!(
921 "relayed payload {} exceeds cap {MAX_RELAY_PAYLOAD}",
922 payload.len()
923 ));
924 }
925 let from = self
926 .status
927 .local_peer_id
928 .lock()
929 .unwrap()
930 .clone()
931 .ok_or("relay reservation not connected — no local peer_id")?;
932 let seq = self.status.relay_seq.fetch_add(1, Ordering::Relaxed);
933 let frame = RelayMessage::RelayGossipMessage {
934 from,
935 to: self.target.clone(),
936 payload,
937 seq,
938 };
939 let guard = self.status.outbound.lock().unwrap();
940 let sink = guard
941 .as_ref()
942 .ok_or("relay reservation not connected — cannot send relayed frame")?;
943 sink.send(frame)
944 .map_err(|_| "relay reservation write half closed".to_string())
945 }
946
947 /// Await the next payload the relay forwards from the target peer. `None` once the reservation
948 /// drops (the session closed) — the caller should re-open the tunnel after the relay reconnects.
949 pub async fn recv(&mut self) -> Option<Vec<u8>> {
950 self.inbound.recv().await
951 }
952
953 /// Poll for the next inbound payload. This is the non-`async` primitive the
954 /// [`RelayTunnelStream`](crate::tunnel::RelayTunnelStream) `AsyncRead` adapter drives so an mTLS
955 /// session can run OVER the relay tunnel. `Poll::Ready(None)` once the reservation drops.
956 pub(crate) fn poll_recv(
957 &mut self,
958 cx: &mut std::task::Context<'_>,
959 ) -> std::task::Poll<Option<Vec<u8>>> {
960 self.inbound.poll_recv(cx)
961 }
962}
963
964impl Drop for RelayTunnel {
965 fn drop(&mut self) {
966 self.status.close_tunnel(&self.target, self.id);
967 }
968}
969
970/// Resolve the relay endpoint: `DIG_RELAY_URL` if set + non-empty (and not the opt-out token), else
971/// the canonical [`dig_constants::DIG_RELAY_URL`].
972pub fn relay_url_from_env() -> String {
973 std::env::var("DIG_RELAY_URL")
974 .ok()
975 .filter(|s| !s.trim().is_empty())
976 .filter(|s| !is_off_token(s))
977 .unwrap_or_else(|| dig_constants::DIG_RELAY_URL.to_string())
978}
979
980/// Whether the relay connection is enabled. Disabled when `DIG_RELAY_URL` is `off`/`disabled`/
981/// empty-after-trim — an explicit opt-out for air-gapped/standalone nodes.
982pub fn relay_enabled() -> bool {
983 match std::env::var("DIG_RELAY_URL") {
984 Ok(v) => !is_off_token(&v),
985 Err(_) => true,
986 }
987}
988
989/// `true` if `v` is the reservation opt-out token (`off`/`disabled`, case-insensitive, trimmed).
990fn is_off_token(v: &str) -> bool {
991 let v = v.trim();
992 v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("disabled")
993}
994
995/// Current unix time (seconds), saturating.
996fn now_secs() -> u64 {
997 std::time::SystemTime::now()
998 .duration_since(std::time::UNIX_EPOCH)
999 .map(|d| d.as_secs())
1000 .unwrap_or(0)
1001}
1002
1003/// Maintain a CONSTANT relay reservation forever: connect, register, keepalive, and on any drop
1004/// reconnect with capped exponential backoff. Spawned as a background task; tolerates the relay
1005/// being down (retries forever, never crashes). `peer_id` is the node's stable identity hex.
1006pub async fn run_relay_connection(
1007 endpoint: String,
1008 peer_id: String,
1009 network_id: String,
1010 listen_addrs: Vec<SocketAddr>,
1011 status: Arc<RelayStatus>,
1012) {
1013 run_relay_connection_with(
1014 endpoint,
1015 peer_id,
1016 network_id,
1017 listen_addrs,
1018 status,
1019 Backoff::default(),
1020 )
1021 .await
1022}
1023
1024/// [`run_relay_connection`] with an explicit backoff schedule (tests pass tiny values for fast,
1025/// deterministic reconnect timing; the LOGIC is identical — only the sleep durations differ).
1026pub async fn run_relay_connection_with(
1027 endpoint: String,
1028 peer_id: String,
1029 network_id: String,
1030 listen_addrs: Vec<SocketAddr>,
1031 status: Arc<RelayStatus>,
1032 backoff: Backoff,
1033) {
1034 let mut consecutive_failures: u32 = 0;
1035 loop {
1036 status.set_connecting();
1037 match connect_once(&endpoint, &peer_id, &network_id, &listen_addrs, &status).await {
1038 Ok(()) => {
1039 consecutive_failures = 0;
1040 status.set_disconnected(None);
1041 }
1042 Err(e) => {
1043 consecutive_failures = consecutive_failures.saturating_add(1);
1044 status.set_disconnected(Some(e));
1045 }
1046 }
1047 // ALWAYS sleep a bounded backoff before retrying — prevents a busy error loop.
1048 let delay = backoff_secs_with(consecutive_failures, backoff.base_secs, backoff.cap_secs);
1049 tokio::time::sleep(Duration::from_secs(delay)).await;
1050 }
1051}
1052
1053/// A relay WebSocket endpoint parsed into the pieces the happy-eyeballs dial needs: the host to
1054/// resolve and the TCP port. The scheme (`ws`/`wss`) only selects the default port here — the
1055/// plaintext-vs-TLS choice is re-derived from the URL by [`client_async_tls_with_config`] during the
1056/// handshake, so a single code path serves both.
1057#[derive(Debug, PartialEq, Eq)]
1058struct RelayEndpoint {
1059 host: String,
1060 port: u16,
1061}
1062
1063/// Parse a relay endpoint URL (`ws://host[:port][/path]` / `wss://host[:port][/path]`, IPv6 hosts in
1064/// `[…]`) into its host + port. Only the authority is needed for the dial; any path/query/fragment and
1065/// userinfo are ignored (the full URL is still handed to the WS handshake for the correct `Host`/SNI).
1066fn parse_relay_endpoint(endpoint: &str) -> Result<RelayEndpoint, String> {
1067 let (scheme, rest) = endpoint
1068 .split_once("://")
1069 .ok_or_else(|| format!("relay endpoint missing scheme: {endpoint}"))?;
1070 let default_port = match scheme.to_ascii_lowercase().as_str() {
1071 "ws" => 80,
1072 "wss" => 443,
1073 other => return Err(format!("unsupported relay scheme: {other}")),
1074 };
1075 // Authority only: drop any path/query/fragment, then any `userinfo@`.
1076 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
1077 let authority = authority
1078 .rsplit_once('@')
1079 .map(|(_, h)| h)
1080 .unwrap_or(authority);
1081
1082 let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
1083 // Bracketed IPv6 literal: `[addr]` or `[addr]:port`.
1084 let (h, after) = stripped
1085 .split_once(']')
1086 .ok_or_else(|| format!("malformed IPv6 authority: {authority}"))?;
1087 let port = match after.strip_prefix(':') {
1088 Some(p) => p.parse().map_err(|_| format!("bad relay port: {after}"))?,
1089 None => default_port,
1090 };
1091 (h.to_string(), port)
1092 } else if let Some((h, p)) = authority.rsplit_once(':') {
1093 (
1094 h.to_string(),
1095 p.parse().map_err(|_| format!("bad relay port: {p}"))?,
1096 )
1097 } else {
1098 (authority.to_string(), default_port)
1099 };
1100
1101 if host.is_empty() {
1102 return Err(format!("relay endpoint missing host: {endpoint}"));
1103 }
1104 Ok(RelayEndpoint { host, port })
1105}
1106
1107/// Resolve a relay host to its family-tagged dial candidates: a literal IP yields one candidate (no
1108/// DNS), a hostname is resolved to its full A + AAAA set. The candidates feed `dig_ip::connect`, which
1109/// applies the §5.2 IPv6-first preference + local∩peer family intersection, so no ordering is imposed
1110/// here — the addresses are added as resolved and tagged by family for observability.
1111async fn resolve_relay_candidates(host: &str, port: u16) -> Result<PeerCandidates, String> {
1112 let mut candidates = PeerCandidates::new();
1113 let source_for = |ip: &IpAddr| {
1114 if ip.is_ipv6() {
1115 CandidateSource::DnsAAAA
1116 } else {
1117 CandidateSource::DnsA
1118 }
1119 };
1120 if let Ok(ip) = host.parse::<IpAddr>() {
1121 candidates.add(SocketAddr::new(ip, port), source_for(&ip));
1122 } else {
1123 let resolved = tokio::net::lookup_host((host, port))
1124 .await
1125 .map_err(|e| format!("resolve {host}:{port}: {e}"))?;
1126 for addr in resolved {
1127 candidates.add(addr, source_for(&addr.ip()));
1128 }
1129 }
1130 if candidates.is_empty() {
1131 return Err(format!("no addresses resolved for {host}:{port}"));
1132 }
1133 Ok(candidates)
1134}
1135
1136/// Race the relay `candidates` IPv6-first with graceful IPv4 fallback via `dig_ip::connect` (§5.2,
1137/// RFC 8305). The transport connect stays a caller-supplied closure so the racing logic is unit-tested
1138/// with a fake dial (no real DNS/sockets) exactly as the direct-peer dialer does in `dialer.rs`; the
1139/// production caller ([`open_relay_ws`]) hands it a real [`TcpStream::connect`].
1140async fn race_relay_candidates<C, F, Fut>(
1141 local: &LocalStack,
1142 candidates: &PeerCandidates,
1143 config: DialConfig,
1144 dial_fn: F,
1145) -> Result<dig_ip::DialWinner<C>, String>
1146where
1147 F: Fn(SocketAddr) -> Fut + Sync,
1148 Fut: std::future::Future<Output = Result<C, String>> + Send,
1149 C: Send,
1150{
1151 dig_ip::connect(local, candidates, config, dial_fn)
1152 .await
1153 .map_err(|e| format!("relay happy-eyeballs dial: {e}"))
1154}
1155
1156/// Open the relay WebSocket over an IPv6-first happy-eyeballs TCP race (§5.2), matching the direct-peer
1157/// dial path in `dialer.rs`: resolve the endpoint host to its A + AAAA candidates, race the TCP connect
1158/// via `dig_ip::connect` (IPv6-first, fast IPv4 fallback), then run the WS handshake over the WINNING
1159/// socket — TLS-over-that-stream for `wss://`, plaintext for `ws://` (the mode is taken from the URL by
1160/// [`client_async_tls_with_config`]). Replaces `tokio_tungstenite::connect_async`, whose sequential,
1161/// single-family resolve-and-connect contradicted the IPv6-first reservation guarantee.
1162async fn open_relay_ws(
1163 endpoint: &str,
1164) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, String> {
1165 let parsed = parse_relay_endpoint(endpoint)?;
1166 let candidates = resolve_relay_candidates(&parsed.host, parsed.port).await?;
1167 let local = LocalStack::cached();
1168 let winner = race_relay_candidates(
1169 &local,
1170 &candidates,
1171 DialConfig::default(),
1172 |addr| async move {
1173 TcpStream::connect(addr)
1174 .await
1175 .map_err(|e| format!("tcp connect {addr}: {e}"))
1176 },
1177 )
1178 .await?;
1179 let (ws, _resp) = client_async_tls_with_config(endpoint, winner.conn, None, None)
1180 .await
1181 .map_err(|e| format!("ws handshake: {e}"))?;
1182 Ok(ws)
1183}
1184
1185/// One connect → register → serve cycle. Returns `Ok` on a clean close, `Err(reason)` on failure.
1186async fn connect_once(
1187 endpoint: &str,
1188 peer_id: &str,
1189 network_id: &str,
1190 listen_addrs: &[SocketAddr],
1191 status: &Arc<RelayStatus>,
1192) -> Result<(), String> {
1193 // Each session's discovered-peer set + transport are independent — never carry state across a
1194 // drop. `clear_transport` also runs at the end so a dropped session's tunnels/sink never linger.
1195 status.clear_known_peers();
1196 status.clear_transport();
1197
1198 let ws = open_relay_ws(endpoint).await?;
1199 let (mut write, mut read) = ws.split();
1200
1201 // RLY-001: register immediately so the relay holds our reservation, advertising the node's gossip
1202 // listen candidates (B1) so the relay can hand other peers a dialable candidate (§5.2 IPv6-first).
1203 let register = RelayMessage::Register {
1204 peer_id: peer_id.to_string(),
1205 network_id: network_id.to_string(),
1206 protocol_version: RELAY_PROTOCOL_VERSION,
1207 listen_addrs: listen_addrs.to_vec(),
1208 };
1209 send(&mut write, ®ister).await?;
1210
1211 // Publish the outbound sink so RLY-002 relayed tunnels can reuse THIS persistent socket. Drained
1212 // in the select loop below; cleared when the session ends.
1213 let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayMessage>();
1214 status.set_transport(peer_id, network_id, out_tx);
1215
1216 // RLY-005: pull the current peer list right away, then again periodically — all over THIS
1217 // persistent socket, so discovery never requires reopening a connection.
1218 let get_peers = RelayMessage::GetPeers {
1219 network_id: Some(network_id.to_string()),
1220 };
1221 send(&mut write, &get_peers).await?;
1222
1223 let mut ping = tokio::time::interval(Duration::from_secs(PING_INTERVAL_SECS));
1224 ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1225 ping.tick().await; // skip the immediate first tick
1226
1227 let mut discovery = tokio::time::interval(Duration::from_secs(DISCOVERY_INTERVAL_SECS));
1228 discovery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1229 discovery.tick().await; // skip the immediate first tick (we already pulled once above)
1230
1231 // Run the session; whatever the outcome, tear the transport down so a dropped session never
1232 // leaves a stale outbound sink or open tunnels behind (they'd send into a closed socket).
1233 let result = serve_session(
1234 &mut write,
1235 &mut read,
1236 &mut ping,
1237 &mut discovery,
1238 &mut out_rx,
1239 network_id,
1240 status,
1241 )
1242 .await;
1243 status.clear_transport();
1244 result
1245}
1246
1247/// The connected-session select loop: keepalive pings, periodic RLY-005 discovery, draining the
1248/// outbound relayed-transport sink onto the socket, and handling inbound frames. Returns `Ok` on a
1249/// clean close, `Err(reason)` on a failure. Split out of `connect_once` so its caller can always run
1250/// transport teardown regardless of how the session ends.
1251#[allow(clippy::too_many_arguments)]
1252async fn serve_session<W, R>(
1253 write: &mut W,
1254 read: &mut R,
1255 ping: &mut tokio::time::Interval,
1256 discovery: &mut tokio::time::Interval,
1257 out_rx: &mut mpsc::UnboundedReceiver<RelayMessage>,
1258 network_id: &str,
1259 status: &Arc<RelayStatus>,
1260) -> Result<(), String>
1261where
1262 W: SinkExt<Message> + Unpin,
1263 <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1264 R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
1265{
1266 loop {
1267 tokio::select! {
1268 _ = ping.tick() => {
1269 send(write, &RelayMessage::Ping { timestamp: now_secs() }).await?;
1270 }
1271 _ = discovery.tick() => {
1272 send(write, &RelayMessage::GetPeers {
1273 network_id: Some(network_id.to_string()),
1274 }).await?;
1275 }
1276 // A relayed tunnel queued an RLY-002 frame — forward it over THIS persistent socket.
1277 Some(frame) = out_rx.recv() => {
1278 send(write, &frame).await?;
1279 }
1280 frame = read.next() => {
1281 match frame {
1282 None => return Ok(()),
1283 Some(Err(e)) => return Err(format!("read: {e}")),
1284 Some(Ok(Message::Close(_))) => return Ok(()),
1285 Some(Ok(Message::Ping(p))) => {
1286 write.send(Message::Pong(p)).await.map_err(|e| format!("pong: {e}"))?;
1287 }
1288 Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
1289 Some(Ok(Message::Text(t))) => {
1290 handle_incoming(t.into_bytes(), write, status).await?;
1291 }
1292 Some(Ok(Message::Binary(b))) => {
1293 handle_incoming(b, write, status).await?;
1294 }
1295 }
1296 }
1297 }
1298 }
1299}
1300
1301/// Handle one decoded inbound relay frame: track RegisterAck (→ connected), answer relay Pings.
1302async fn handle_incoming<W>(
1303 bytes: Vec<u8>,
1304 write: &mut W,
1305 status: &Arc<RelayStatus>,
1306) -> Result<(), String>
1307where
1308 W: SinkExt<Message> + Unpin,
1309 <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1310{
1311 let Ok(msg) = serde_json::from_slice::<RelayMessage>(&bytes) else {
1312 return Ok(()); // ignore anything we can't parse; the relay is untrusted
1313 };
1314 match msg {
1315 RelayMessage::RegisterAck {
1316 success,
1317 message,
1318 connected_peers,
1319 } => {
1320 if success {
1321 status.set_connected(connected_peers as u64);
1322 } else {
1323 return Err(format!("register rejected: {message}"));
1324 }
1325 }
1326 RelayMessage::Ping { timestamp } => {
1327 send(write, &RelayMessage::Pong { timestamp }).await?;
1328 }
1329 // RLY-005 + push notices: fold peers discovered over the live socket into the status so the
1330 // consumer's pool/address book sees them without opening an ephemeral discovery connection.
1331 RelayMessage::Peers { peers } => status.replace_known_peers(peers),
1332 RelayMessage::PeerConnected { peer } => status.add_known_peer(peer),
1333 RelayMessage::PeerDisconnected { peer_id } => status.remove_known_peer(&peer_id),
1334 // RLY-002 relayed transport (tier-6 TURN): route a payload the relay forwarded from `from` to
1335 // that peer's open tunnel. Unknown-peer / oversized / full-channel frames are dropped inside
1336 // `route_relayed` (untrusted-relay defense). `to`/`seq` are the relay's concern; we key on
1337 // `from`. Per NC-1 `payload` is sealed ciphertext the relay could not read.
1338 RelayMessage::RelayGossipMessage { from, payload, .. } => {
1339 status.route_relayed(&from, payload)
1340 }
1341 // RLY-009 (#1935): the relay is asking what this node holds in its DHT provider store.
1342 // Answered only if the consumer registered a reader; otherwise we stay silent, which a
1343 // pre-RLY-009 relay and a non-participating node are equally free to do.
1344 RelayMessage::GetDhtRecords { max_keys } => {
1345 if let Some(reader) = status.dht_records_provider() {
1346 let answer = reader(max_keys);
1347 send(
1348 write,
1349 &RelayMessage::DhtRecords {
1350 records: answer.records,
1351 total_keys: answer.total_keys,
1352 truncated: answer.truncated,
1353 },
1354 )
1355 .await?;
1356 }
1357 }
1358 // A node never RECEIVES an answer — it is the one answering. Ignore rather than error, so a
1359 // confused or hostile relay cannot drop this node's reservation by echoing one back.
1360 RelayMessage::DhtRecords { .. } => {
1361 tracing::debug!("ignoring dht_records sent to a client");
1362 }
1363 RelayMessage::Error { code, message } => {
1364 // A relay `Error` frame is NOT automatically a reservation problem — the relay reports
1365 // per-REQUEST failures on the same channel. Treating them all as fatal made a routine
1366 // "the peer you asked for has left" (`PEER_NOT_FOUND`) tear down this node's own
1367 // reservation, de-register it, and force a full reconnect — a flap that repeatedly
1368 // pulled the node out of the relay's introductions (dig_ecosystem #1932).
1369 if relay_error_is_fatal(code) {
1370 return Err(format!("relay error {code}: {message}"));
1371 }
1372 tracing::debug!(code, %message, "relay reported a per-request error; reservation held");
1373 }
1374 other => tracing::debug!(?other, "relay message ignored by reservation loop"),
1375 }
1376 Ok(())
1377}
1378
1379/// Wire two in-memory relay reservations to forward RLY-002 frames to each OTHER — a loopback relay
1380/// with no real network. Each returned [`RelayStatus`] is `Connected` with a live outbound sink whose
1381/// frames are routed into the peer's tunnels by `from` peer_id, exactly as a real relay would forward
1382/// A→relay→B. Used to prove a full mTLS session round-trips over [`RelayTunnel`]s (see `tunnel.rs`).
1383///
1384/// `a` opens tunnels targeting `b_id`; `b` opens tunnels targeting `a_id`.
1385#[cfg(test)]
1386pub(crate) fn loopback_reservation_pair(
1387 a_id: &str,
1388 b_id: &str,
1389) -> (Arc<RelayStatus>, Arc<RelayStatus>) {
1390 let a = RelayStatus::new();
1391 let b = RelayStatus::new();
1392 a.set_connected(1);
1393 b.set_connected(1);
1394
1395 let (a_tx, mut a_rx) = mpsc::unbounded_channel::<RelayMessage>();
1396 let (b_tx, mut b_rx) = mpsc::unbounded_channel::<RelayMessage>();
1397 a.set_transport(a_id, DEFAULT_NETWORK_ID, a_tx);
1398 b.set_transport(b_id, DEFAULT_NETWORK_ID, b_tx);
1399
1400 // Drain a's outbound → forward into b (route by `from`), and symmetrically b → a. This is the
1401 // relay's forwarding role, in-process.
1402 let b_route = Arc::clone(&b);
1403 tokio::spawn(async move {
1404 while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = a_rx.recv().await {
1405 b_route.route_relayed(&from, payload);
1406 }
1407 });
1408 let a_route = Arc::clone(&a);
1409 tokio::spawn(async move {
1410 while let Some(RelayMessage::RelayGossipMessage { from, payload, .. }) = b_rx.recv().await {
1411 a_route.route_relayed(&from, payload);
1412 }
1413 });
1414
1415 (a, b)
1416}
1417
1418/// Serialize + send one `RelayMessage` as a WebSocket text frame.
1419async fn send<W>(write: &mut W, msg: &RelayMessage) -> Result<(), String>
1420where
1421 W: SinkExt<Message> + Unpin,
1422 <W as futures_util::Sink<Message>>::Error: std::fmt::Display,
1423{
1424 let txt = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?;
1425 write
1426 .send(Message::Text(txt))
1427 .await
1428 .map_err(|e| format!("send: {e}"))
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434 use dig_ip::Family;
1435 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
1436 use std::sync::Mutex as StdMutex;
1437
1438 // -- #1932: a per-request relay error must not cost us the reservation -------------------
1439
1440 // -- #1935: RLY-009 aggregated DHT-record answers -----------------------------------------
1441
1442 #[test]
1443 fn a_node_answers_nothing_until_a_reader_is_registered() {
1444 // The opted-out default. A node that has not opted in must be indistinguishable on the wire
1445 // from a pre-RLY-009 one — silence, not an empty answer, and certainly not an error.
1446 let status = RelayStatus::new();
1447 assert!(
1448 status.dht_records_provider().is_none(),
1449 "no reader by default"
1450 );
1451 }
1452
1453 #[test]
1454 fn a_registered_reader_receives_the_relays_bound_and_its_answer_is_returned() {
1455 // The bound must reach the reader: the provider store is attacker-influenced, so an answer
1456 // that ignored max_keys would let a Sybil dictate the frame size on the reservation socket.
1457 let status = RelayStatus::new();
1458 let seen: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1459 let seen_w = Arc::clone(&seen);
1460 status.set_dht_records_provider(move |max_keys| {
1461 seen_w.lock().unwrap().push(max_keys);
1462 DhtRecordsAnswer {
1463 records: vec![crate::wire::DhtRecordEntry {
1464 content_key: "cd".repeat(32),
1465 providers: 4,
1466 }],
1467 total_keys: 9,
1468 truncated: true,
1469 }
1470 });
1471
1472 let reader = status.dht_records_provider().expect("reader registered");
1473 let answer = reader(16);
1474
1475 assert_eq!(
1476 *seen.lock().unwrap(),
1477 vec![16],
1478 "the bound reached the reader"
1479 );
1480 assert_eq!(answer.total_keys, 9);
1481 assert!(answer.truncated);
1482 assert_eq!(answer.records[0].providers, 4);
1483 }
1484
1485 #[test]
1486 fn the_answer_carries_counts_and_no_identity() {
1487 // Same property the wire test pins, asserted at the type level so it holds for any caller
1488 // constructing an answer, not only for the one serialized shape.
1489 let answer = DhtRecordsAnswer {
1490 records: vec![crate::wire::DhtRecordEntry {
1491 content_key: "ef".repeat(32),
1492 providers: 2,
1493 }],
1494 total_keys: 1,
1495 truncated: false,
1496 };
1497 let rendered = format!("{answer:?}");
1498 assert!(
1499 !rendered.contains("peer_id"),
1500 "no identity field: {rendered}"
1501 );
1502 }
1503
1504 #[test]
1505 fn peer_not_found_is_not_fatal_because_it_is_about_another_peer() {
1506 // The exact frame that was flapping the fleet: the peer we dialled had left the relay.
1507 // Routine on a live network, and no statement at all about our own registration.
1508 assert!(!relay_error_is_fatal(3));
1509 }
1510
1511 #[test]
1512 fn a_bad_frame_we_sent_costs_that_frame_not_the_reservation() {
1513 assert!(!relay_error_is_fatal(2));
1514 }
1515
1516 #[test]
1517 fn every_code_that_means_our_registration_failed_is_fatal() {
1518 // 1 NOT_REGISTERED, and the four that accompany a failing register_ack. For these the
1519 // reservation genuinely does not exist, so ending the loop and re-registering is correct.
1520 for code in [1, 4, 5, 6, 7] {
1521 assert!(
1522 relay_error_is_fatal(code),
1523 "code {code} invalidates the reservation and must end the loop"
1524 );
1525 }
1526 }
1527
1528 #[test]
1529 fn an_unknown_future_code_keeps_the_reservation() {
1530 // Deliberate asymmetry: a wrong guess here costs one log line, whereas defaulting to fatal
1531 // would let a newly-introduced code take every node off its relay simultaneously.
1532 for code in [0, 8, 99, u32::MAX] {
1533 assert!(!relay_error_is_fatal(code), "code {code} must not be fatal");
1534 }
1535 }
1536
1537 #[test]
1538 fn parses_wss_host_and_explicit_port() {
1539 let ep = parse_relay_endpoint("wss://relay.dig.net:443").unwrap();
1540 assert_eq!(ep.host, "relay.dig.net");
1541 assert_eq!(ep.port, 443);
1542 }
1543
1544 #[test]
1545 fn parses_default_ports_by_scheme() {
1546 assert_eq!(
1547 parse_relay_endpoint("wss://relay.dig.net").unwrap().port,
1548 443
1549 );
1550 assert_eq!(parse_relay_endpoint("ws://relay.dig.net").unwrap().port, 80);
1551 }
1552
1553 #[test]
1554 fn parses_bracketed_ipv6_authority_with_and_without_port() {
1555 let with_port = parse_relay_endpoint("wss://[2001:db8::1]:8443").unwrap();
1556 assert_eq!(with_port.host, "2001:db8::1");
1557 assert_eq!(with_port.port, 8443);
1558 let no_port = parse_relay_endpoint("wss://[2001:db8::1]/ws").unwrap();
1559 assert_eq!(no_port.host, "2001:db8::1");
1560 assert_eq!(no_port.port, 443);
1561 }
1562
1563 #[test]
1564 fn ignores_path_query_and_userinfo() {
1565 let ep = parse_relay_endpoint("wss://user@relay.dig.net:9443/ws?x=1#f").unwrap();
1566 assert_eq!(ep.host, "relay.dig.net");
1567 assert_eq!(ep.port, 9443);
1568 }
1569
1570 #[test]
1571 fn rejects_malformed_endpoints() {
1572 assert!(parse_relay_endpoint("relay.dig.net:443").is_err()); // no scheme
1573 assert!(parse_relay_endpoint("http://relay.dig.net").is_err()); // wrong scheme
1574 assert!(parse_relay_endpoint("wss://relay.dig.net:notaport").is_err());
1575 }
1576
1577 #[tokio::test]
1578 async fn resolve_relay_candidates_handles_ip_literals_without_dns() {
1579 let v6 = resolve_relay_candidates("2001:db8::1", 443).await.unwrap();
1580 assert_eq!(v6.all().len(), 1);
1581 assert_eq!(v6.all()[0].family, Family::V6);
1582 assert_eq!(v6.all()[0].source, CandidateSource::DnsAAAA);
1583
1584 let v4 = resolve_relay_candidates("203.0.113.7", 443).await.unwrap();
1585 assert_eq!(v4.all()[0].family, Family::V4);
1586 assert_eq!(v4.all()[0].source, CandidateSource::DnsA);
1587 }
1588
1589 /// The relay dial races BOTH families and falls back to IPv4 when the IPv6 candidate is dead —
1590 /// the §5.2 happy-eyeballs guarantee, proven with a FAKE dial closure (no real DNS/sockets). A
1591 /// dead IPv6 candidate + a live IPv4 candidate on a dual-stack host must yield the IPv4 winner,
1592 /// and BOTH families must have been attempted.
1593 #[tokio::test]
1594 async fn relay_dial_races_both_families_and_falls_back_to_ipv4() {
1595 let mut candidates = PeerCandidates::new();
1596 let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1597 let v4: SocketAddr = "203.0.113.7:443".parse().unwrap();
1598 candidates.add(v6, CandidateSource::DnsAAAA);
1599 candidates.add(v4, CandidateSource::DnsA);
1600
1601 let dual = LocalStack::from_flags(true, true);
1602 let attempted: StdMutex<Vec<SocketAddr>> = StdMutex::new(Vec::new());
1603 // Fast attempt-delay so the hedged IPv4 starts promptly once the IPv6 attempt fails.
1604 let cfg = DialConfig {
1605 per_attempt_timeout: Duration::from_secs(1),
1606 attempt_delay: Duration::from_millis(5),
1607 };
1608
1609 let winner = race_relay_candidates(&dual, &candidates, cfg, |addr| {
1610 let attempted = &attempted;
1611 async move {
1612 attempted.lock().unwrap().push(addr);
1613 if addr.is_ipv6() {
1614 Err(format!("simulated dead IPv6 {addr}"))
1615 } else {
1616 Ok(addr) // the fake "connection" is just the address that won
1617 }
1618 }
1619 })
1620 .await
1621 .expect("IPv4 fallback wins when IPv6 is dead");
1622
1623 assert_eq!(winner.conn, v4, "the live IPv4 candidate won");
1624 assert_eq!(winner.family, Family::V4);
1625 let tried = attempted.lock().unwrap();
1626 assert!(
1627 tried.contains(&v6),
1628 "the IPv6 candidate was attempted first"
1629 );
1630 assert!(
1631 tried.contains(&v4),
1632 "the IPv4 candidate was attempted as fallback"
1633 );
1634 }
1635
1636 /// IPv6 is the PREFERENCE, not merely first-attempted: with both families live on a dual-stack
1637 /// host, the IPv6 candidate wins the race (IPv4 is only a fallback).
1638 #[tokio::test]
1639 async fn relay_dial_prefers_ipv6_when_both_live() {
1640 let mut candidates = PeerCandidates::new();
1641 let v6: SocketAddr = "[2001:db8::2]:443".parse().unwrap();
1642 let v4: SocketAddr = "203.0.113.8:443".parse().unwrap();
1643 candidates.add(v6, CandidateSource::DnsAAAA);
1644 candidates.add(v4, CandidateSource::DnsA);
1645
1646 let dual = LocalStack::from_flags(true, true);
1647 let calls = AtomicUsize::new(0);
1648 let winner = race_relay_candidates(&dual, &candidates, DialConfig::default(), |addr| {
1649 calls.fetch_add(1, AtomicOrdering::Relaxed);
1650 async move { Ok::<SocketAddr, String>(addr) }
1651 })
1652 .await
1653 .unwrap();
1654
1655 assert_eq!(winner.conn, v6, "IPv6 preferred when both are viable");
1656 assert_eq!(winner.family, Family::V6);
1657 }
1658
1659 /// A minimal TLS handshake record whose first message is a ClientHello — the ONLY frame that opens
1660 /// an introduced circuit (#1761), so every test that drives the responder path must send this shape.
1661 fn client_hello_frame() -> Vec<u8> {
1662 vec![0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0, 0, 0, 0]
1663 }
1664
1665 /// Build a `Connected` status with a live (dummy) outbound sink + local identity, so
1666 /// `route_relayed`'s introduced-circuit path can run without a real relay socket.
1667 fn connected_status(local_id: &str) -> Arc<RelayStatus> {
1668 let status = RelayStatus::new();
1669 status.set_connected(1);
1670 let (out_tx, _out_rx) = mpsc::unbounded_channel::<RelayMessage>();
1671 status.set_transport(local_id, DEFAULT_NETWORK_ID, out_tx);
1672 // These tests exercise the introduced-circuit ROUTING (register/accept/drop), which never uses
1673 // the outbound sink, so the receiver may drop at end of scope — the sink stays `Some`.
1674 status
1675 }
1676
1677 /// SECURITY (accept OFF): with NO responder path enabled, an introduced RLY-002 frame from an
1678 /// unknown peer is DROPPED — no tunnel is created and nothing is surfaced. This is the
1679 /// untrusted-relay default: a node that never opted into accepting relayed circuits cannot be made
1680 /// to spawn one by a hostile relay.
1681 #[test]
1682 fn introduced_frame_dropped_when_accept_disabled() {
1683 let status = connected_status("00aa");
1684 // A ClientHello-shaped frame from a peer we hold NO tunnel to — the introduced-circuit trigger.
1685 status.route_relayed("ffbb", client_hello_frame());
1686 assert!(
1687 !status.open_tunnel_exists("ffbb"),
1688 "no tunnel surfaced for an introduced circuit while accept is off"
1689 );
1690 assert_eq!(
1691 status.tunnels.lock().unwrap().len(),
1692 0,
1693 "accept-off drops the introduced circuit entirely"
1694 );
1695 }
1696
1697 /// SECURITY (flood defense, tunnel cap): once [`MAX_RELAY_TUNNELS`] tunnels are open, a further
1698 /// introduced circuit from a new peer is DROPPED rather than registered — a hostile relay flooding
1699 /// distinct fabricated `from` ids cannot spawn unbounded server tunnels/accept-tasks.
1700 #[test]
1701 fn introduced_circuit_dropped_at_max_tunnels_cap() {
1702 let status = connected_status("00aa");
1703 let mut accept_rx = status.enable_accept();
1704 // Saturate the tunnel table at the cap (held open by the returned RelayTunnels).
1705 let mut held = Vec::new();
1706 for i in 0..MAX_RELAY_TUNNELS {
1707 held.push(status.register_tunnel(
1708 &format!("peer{i:05}"),
1709 DEFAULT_NETWORK_ID,
1710 TunnelRole::Server,
1711 ));
1712 }
1713 assert_eq!(status.tunnels.lock().unwrap().len(), MAX_RELAY_TUNNELS);
1714
1715 status.route_relayed("overflowpeer", client_hello_frame());
1716 assert!(
1717 !status.open_tunnel_exists("overflowpeer"),
1718 "an introduced circuit beyond the tunnel cap is dropped, not registered"
1719 );
1720 assert_eq!(
1721 status.tunnels.lock().unwrap().len(),
1722 MAX_RELAY_TUNNELS,
1723 "tunnel count never grows past the cap"
1724 );
1725 assert!(
1726 accept_rx.try_recv().is_err(),
1727 "the capped circuit is never surfaced to the acceptor"
1728 );
1729 drop(held);
1730 }
1731
1732 /// SECURITY (flood defense, accept channel): when the bounded inbound-accept channel
1733 /// ([`INBOUND_ACCEPT_CAP`]) is full — the consumer is not accepting fast enough — a further
1734 /// introduced circuit is DROPPED (its freshly-registered tunnel is torn down), bounded
1735 /// backpressure rather than unbounded queueing.
1736 #[test]
1737 fn introduced_circuit_dropped_when_accept_channel_full() {
1738 let status = connected_status("00aa");
1739 let mut accept_rx = status.enable_accept();
1740 // Fill the accept channel to capacity WITHOUT draining it — each surfaced circuit occupies one
1741 // slot and keeps its server tunnel registered (the RelayTunnel lives in the channel).
1742 for i in 0..INBOUND_ACCEPT_CAP {
1743 status.route_relayed(&format!("in{i:05}"), client_hello_frame());
1744 }
1745 assert_eq!(
1746 status.tunnels.lock().unwrap().len(),
1747 INBOUND_ACCEPT_CAP,
1748 "each surfaced circuit registered exactly one server tunnel"
1749 );
1750
1751 // One more: the accept channel is full → the tunnel is registered then immediately dropped,
1752 // so its routing is deregistered and nothing new is surfaced.
1753 status.route_relayed("overflow", client_hello_frame());
1754 assert!(
1755 !status.open_tunnel_exists("overflow"),
1756 "an introduced circuit is dropped when the accept channel is full"
1757 );
1758
1759 let mut surfaced = 0;
1760 while accept_rx.try_recv().is_ok() {
1761 surfaced += 1;
1762 }
1763 assert_eq!(
1764 surfaced, INBOUND_ACCEPT_CAP,
1765 "exactly the channel capacity surfaced — never the overflow circuit"
1766 );
1767 }
1768
1769 /// REGRESSION (#1536 glare, TIMING order): a peer's ClientHello arrives BEFORE our own dial to it
1770 /// registers. We accept it as a server; our later dial to the SAME peer MUST then be refused
1771 /// (non-clobber) so no conflicting second circuit / double mTLS session is created. This is the
1772 /// deeper ordering the first tie-break missed (role was decided by who-registered-first).
1773 #[test]
1774 fn clienthello_before_local_dial_does_not_double_register() {
1775 let status = connected_status("bbbb");
1776 let mut accept_rx = status.enable_accept();
1777
1778 // The peer's introduced ClientHello arrives first → we accept it as a server-role circuit.
1779 status.route_relayed("aaaa", client_hello_frame());
1780 assert!(status.open_tunnel_exists("aaaa"));
1781 let _server_tunnel = accept_rx
1782 .try_recv()
1783 .expect("introduced circuit surfaced as a server");
1784
1785 // Our own dial to that peer now must be REFUSED — the existing circuit is the connection.
1786 let dial = status.open_tunnel("aaaa", DEFAULT_NETWORK_ID);
1787 assert!(
1788 dial.is_err(),
1789 "a second dial to a peer we already serve is refused (no double-session)"
1790 );
1791 assert_eq!(
1792 status.tunnels.lock().unwrap().len(),
1793 1,
1794 "exactly one circuit per peer — never a conflicting client+server pair"
1795 );
1796 }
1797
1798 /// REGRESSION (#1536 equal-id): a relayed self-dial, or a frame stamped with our OWN peer_id
1799 /// (theoretical SPKI collision / a hostile relay reflecting our id), has no lower/higher end for
1800 /// the tie-break — it MUST be rejected outright, never producing a no-server hang.
1801 #[test]
1802 fn self_dial_and_self_stamped_frame_rejected() {
1803 let status = connected_status("cccc");
1804 assert!(
1805 status.open_tunnel("cccc", DEFAULT_NETWORK_ID).is_err(),
1806 "a relayed self-dial (target == local id) is refused"
1807 );
1808
1809 let mut accept_rx = status.enable_accept();
1810 status.route_relayed("cccc", client_hello_frame());
1811 assert!(
1812 !status.open_tunnel_exists("cccc"),
1813 "a frame stamped with our own id is dropped, never registered"
1814 );
1815 assert!(
1816 accept_rx.try_recv().is_err(),
1817 "a self-stamped frame is never surfaced as an accept"
1818 );
1819 }
1820
1821 /// SECURITY (#1536 relay-injected-ClientHello DoS): an untrusted relay can inject a bogus
1822 /// ClientHello on a lower-id node's client tunnel to force it to yield its outbound dial to a
1823 /// server accept that no real peer completes. mTLS identity is never bypassed, but the outbound
1824 /// dial MUST NOT be permanently lost — once the bogus (never-completing) server circuit is dropped,
1825 /// the peer key frees and a fresh dial is possible.
1826 #[test]
1827 fn injected_clienthello_yield_does_not_permanently_block_redial() {
1828 // local id "00aa" is numerically lower than the peer "ffff", so we are the yield-to-server side.
1829 let status = connected_status("00aa");
1830 let mut accept_rx = status.enable_accept();
1831
1832 let client_tunnel = status
1833 .open_tunnel("ffff", DEFAULT_NETWORK_ID)
1834 .expect("outbound relayed dial opens");
1835 assert!(status.open_tunnel_exists("ffff"));
1836
1837 // The relay injects a bogus ClientHello with from=ffff → glare on our client tunnel → we (the
1838 // lower id) yield: drop the client tunnel, surface a server accept.
1839 status.route_relayed("ffff", client_hello_frame());
1840 let server_tunnel = accept_rx
1841 .try_recv()
1842 .expect("the injected ClientHello yielded a server accept");
1843
1844 // The original outbound dial is cancelled; dropping its handle must NOT evict the newer server
1845 // entry (generation-id guard).
1846 drop(client_tunnel);
1847 assert!(
1848 status.open_tunnel_exists("ffff"),
1849 "the server circuit survives the cancelled client dial's drop"
1850 );
1851
1852 // The bogus circuit completes no handshake; once its tunnel is dropped the key frees...
1853 drop(server_tunnel);
1854 assert!(
1855 !status.open_tunnel_exists("ffff"),
1856 "dropping the never-completing server circuit releases the peer key"
1857 );
1858 // ...and a fresh dial is possible — no permanent lockout from the injected frame.
1859 assert!(
1860 status.open_tunnel("ffff", DEFAULT_NETWORK_ID).is_ok(),
1861 "a fresh outbound dial succeeds after the bogus injection is cleaned up"
1862 );
1863 }
1864
1865 /// REGRESSION (#1761): only a client's OPENING HANDSHAKE may create a server-role circuit.
1866 ///
1867 /// A relayed frame from a peer we hold no tunnel to used to be accepted as an introduced circuit
1868 /// WHATEVER its content, so any frame that is not a dialer's ClientHello manufactured a bogus
1869 /// server-role circuit — and the mTLS server behind it then failed with the live
1870 /// `got ServerHello when expecting ClientHello` (both ends running the TLS server role).
1871 ///
1872 /// The frames covered here are the whole CLASS of "not the start of an inbound circuit", each with
1873 /// a real production or adversarial origin: a peer's ServerHello or application record arriving
1874 /// after we released our client tunnel (a `fast_connect` relayed→direct promotion drops the
1875 /// per-peer tunnel while the peer's frames are still in flight), a truncated record too short to
1876 /// classify, and arbitrary relay garbage. The final ClientHello is the truthful CONTROL: the
1877 /// responder path still works, and dropping the stray frames never blacklists the peer.
1878 #[test]
1879 fn only_a_clienthello_creates_an_introduced_circuit() {
1880 let status = connected_status("00aa");
1881 let mut accept_rx = status.enable_accept();
1882
1883 // A TLS handshake record whose message type is ServerHello (0x02) — the live #1761 frame.
1884 let server_hello = vec![0x16, 0x03, 0x03, 0x00, 0x05, 0x02, 0, 0, 0, 0];
1885 // A TLS application-data record (0x17) — a mid-session frame from a released tunnel.
1886 let app_data = vec![0x17, 0x03, 0x03, 0x00, 0x04, 0xde, 0xad, 0xbe, 0xef];
1887 // A record header truncated before the handshake-message byte — unclassifiable, so not an
1888 // opening handshake.
1889 let truncated = vec![0x16, 0x03, 0x03, 0x00, 0x05];
1890 // Not TLS at all.
1891 let garbage = vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
1892
1893 for (label, frame) in [
1894 ("ServerHello", server_hello),
1895 ("application record", app_data),
1896 ("truncated record", truncated),
1897 ("garbage", garbage),
1898 ] {
1899 status.route_relayed("ffbb", frame);
1900 assert!(
1901 !status.open_tunnel_exists("ffbb"),
1902 "a {label} frame must not register a server-role circuit"
1903 );
1904 assert!(
1905 accept_rx.try_recv().is_err(),
1906 "a {label} frame must not surface an accept"
1907 );
1908 }
1909
1910 // CONTROL: the peer's genuine ClientHello still opens the circuit — the responder path is
1911 // intact and the earlier drops left no per-peer state behind.
1912 status.route_relayed("ffbb", client_hello_frame());
1913 assert!(
1914 status.open_tunnel_exists("ffbb"),
1915 "a genuine ClientHello still opens an introduced circuit"
1916 );
1917 assert!(
1918 accept_rx.try_recv().is_ok(),
1919 "a genuine ClientHello still surfaces an accept"
1920 );
1921 }
1922}