From 337220299fba1902bf8c5a3264d515b3db5144a9 Mon Sep 17 00:00:00 2001 From: Goblin Date: Thu, 2 Jul 2026 10:06:50 -0400 Subject: [PATCH] nym: remove the dead scoped-exit egress The exit is unpinned everywhere, so the streamexit module + the exit_for/ exit_connect forks in transport + http were dead code. Remove them; the wallet's only mixnet path is the smolmix tunnel. --- Cargo.toml | 2 +- src/nostr/pool.rs | 86 ----------------- src/nym/mod.rs | 97 +++++-------------- src/nym/nymproc.rs | 12 +-- src/nym/streamexit.rs | 218 +----------------------------------------- src/nym/transport.rs | 70 +------------- 6 files changed, 34 insertions(+), 451 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3c2cd63..aa79198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [lib] name="grim" -crate-type = ["rlib"] +crate-type = ["cdylib","rlib"] # Desktop/CI release binaries ship stripped of debug symbols — the nym + nostr + # grin tree leaves a large symbol table that's dead weight for users (~16 MB on diff --git a/src/nostr/pool.rs b/src/nostr/pool.rs index 53e86af..1fb9940 100644 --- a/src/nostr/pool.rs +++ b/src/nostr/pool.rs @@ -91,18 +91,6 @@ pub struct PoolRelay { /// Last-vetted date; presence marks the entry as vetted. #[serde(default)] pub vetted: Option, - /// This relay operator's CO-LOCATED Nym exit address, when they run one (the - /// bundled floonet-rs / floonet-strfry `exit = true` feature). It is a Nym - /// `Recipient` (`.@`) for a SCOPED MixnetStream proxy - /// that forwards ONLY to this relay — so the wallet can reach the relay over - /// the mixnet WITHOUT public DNS and WITHOUT depending on a public IPR exit - /// (the anchor; see [`crate::nym::nymproc`]). Absent → this relay is reached - /// the old way (public-IPR smolmix + in-tunnel DoT). Carried in the pinned - /// pool so the money-path default relay's exit bootstraps OFFLINE, before any - /// network — breaking the chicken-and-egg of learning it over the very path - /// it is meant to replace. - #[serde(default)] - pub exit: Option, } impl PoolRelay { @@ -149,36 +137,6 @@ impl RelayPool { .map(|r| r.url.clone()) .collect() } - - /// The operator's co-located Nym exit address for `url`, if the pool - /// advertises one (url compared modulo a trailing slash). `None` → reach the - /// relay over the public-IPR path as before. This is how the wallet learns - /// the anchor exit for its money-path relay (see [`PoolRelay::exit`]). - pub fn exit_for(&self, url: &str) -> Option { - let want = url.trim_end_matches('/'); - self.relays - .iter() - .find(|r| r.url.trim_end_matches('/') == want) - .and_then(|r| r.exit.clone()) - .filter(|e| !e.trim().is_empty()) - } - - /// Like [`Self::exit_for`], but keyed on the HOSTNAME — the HTTP dial site - /// ([`crate::nym::request_once`]) knows only `host`, never the relay's ws - /// URL. HTTPS to a host whose relay advertises a co-located exit (its - /// NIP-11 probe, in practice) rides that exit too. - pub fn exit_for_host(&self, host: &str) -> Option { - self.relays - .iter() - .find(|r| { - url::Url::parse(&r.url) - .ok() - .and_then(|u| u.host_str().map(|h| h.eq_ignore_ascii_case(host))) - .unwrap_or(false) - }) - .and_then(|r| r.exit.clone()) - .filter(|e| !e.trim().is_empty()) - } } /// Disk path of the cached pool file. @@ -366,48 +324,6 @@ mod tests { assert!(disc.contains(&"wss://indexer.coracle.social".to_string())); } - #[test] - fn exit_field_is_optional_and_looked_up_by_url() { - // No exit is pinned right now (a second mixnet client cold-boot regresses - // first-connect); the field is still parsed + looked up when present. - let pinned = RelayPool::parse(PINNED_POOL).unwrap(); - assert!(pinned.relays.iter().all(|r| r.exit.is_none())); - assert!(pinned.exit_for("wss://relay.goblin.st").is_none()); - - // A pool that DOES advertise an exit for one relay. - let pool = RelayPool::parse( - r#"{"version":1,"updated":"x","min_message_length":131072,"relays":[ - {"url":"wss://relay.goblin.st/","roles":["dm"],"exit":"aaa.bbb@ccc"}, - {"url":"wss://nos.lol","roles":["dm"]}, - {"url":"wss://blank.example","roles":["dm"],"exit":" "} - ]}"#, - ) - .unwrap(); - // Trailing-slash-insensitive lookup. - assert_eq!( - pool.exit_for("wss://relay.goblin.st"), - Some("aaa.bbb@ccc".to_string()) - ); - // No exit field → None; blank exit → None (treated as unset). - assert!(pool.exit_for("wss://nos.lol").is_none()); - assert!(pool.exit_for("wss://blank.example").is_none()); - // Unknown url → None. - assert!(pool.exit_for("wss://unknown.example").is_none()); - - // Host-keyed lookup (the HTTP dial site): same answers by hostname. - assert_eq!( - pool.exit_for_host("relay.goblin.st"), - Some("aaa.bbb@ccc".to_string()) - ); - assert_eq!( - pool.exit_for_host("RELAY.GOBLIN.ST"), - Some("aaa.bbb@ccc".to_string()) - ); - assert!(pool.exit_for_host("nos.lol").is_none()); - assert!(pool.exit_for_host("blank.example").is_none()); - assert!(pool.exit_for_host("unknown.example").is_none()); - } - #[test] fn pool_validation_rejects_bad_documents() { assert!(RelayPool::parse("not json").is_none()); @@ -475,7 +391,6 @@ mod tests { url: url.to_string(), roles: vec!["dm".to_string()], vetted: vetted.then(|| "2026-07-01".to_string()), - exit: None, }; vec![ mk("wss://a.example", false), @@ -500,7 +415,6 @@ mod tests { url: "wss://relay.goblin.st".to_string(), roles: vec!["dm".to_string()], vetted: Some("2026-07-01".to_string()), - exit: None, }); let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0); assert_eq!(order.len(), 4); diff --git a/src/nym/mod.rs b/src/nym/mod.rs index 95e45b7..09ed2c5 100644 --- a/src/nym/mod.rs +++ b/src/nym/mod.rs @@ -14,16 +14,12 @@ //! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and //! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet: -//! by default one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an -//! auto-selected public IPR exit, so neither the payload nor the -//! destination-in-flight ever touches the clearnet. Hostnames resolve through -//! the same tunnel too ([`dns`], DoT — DNS-over-TLS), so nothing goes -//! clearnet. MONEY-PATH ANCHOR: a host whose relay advertises a co-located -//! scoped exit in the pool is instead dialed over a MixnetStream straight to -//! that exit ([`streamexit`]) — no DNS and no public IPR at all — falling -//! back to the tunnel on any failure. The mixnet breaks the sender↔receiver -//! timing correlation that Mimblewimble's interactive slate exchange -//! otherwise leaks at the network layer. +//! one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an auto-selected +//! public IPR exit, so neither the payload nor the destination-in-flight ever +//! touches the clearnet. Hostnames resolve through the same tunnel too +//! ([`dns`], DoT — DNS-over-TLS), so nothing goes clearnet. The mixnet breaks +//! the sender↔receiver timing correlation that Mimblewimble's interactive +//! slate exchange otherwise leaks at the network layer. //! //! DNS reliability was the one weak spot: the original mix-dns sent UDP over the //! mixnet, and mixnet UDP loses packets — resolves stalled on multi-second @@ -35,7 +31,6 @@ pub mod dns; pub mod nymproc; -pub mod streamexit; pub mod transport; use std::sync::Arc; @@ -137,43 +132,24 @@ async fn request_once( let https = url.scheme() == "https"; let port = url.port().unwrap_or(if https { 443 } else { 80 }); - // MONEY-PATH ANCHOR fork: HTTPS to a host whose relay advertises a - // co-located scoped Nym exit (its NIP-11 probe, in practice) rides a - // MixnetStream to that exit instead of the tunnel — no public DNS, no - // public IPR. Failure just falls through to the tunnel path below (anchor - // + fallback, never pin-only). - let exit_io = if https { - match crate::nostr::pool::load().exit_for_host(&host) { - Some(exit) => exit_connect(&host, &exit).await, - None => None, + // Resolve the host over the tunnel (DoT — see dns), then dial that + // IP through the same tunnel so nothing (lookup or body) touches + // the clear. + let addr = dns::resolve(tunnel, &host, port).await?; + let tcp = match tunnel.tcp_connect(addr).await { + Ok(s) => s, + Err(e) => { + warn!("nym http: connect to {host} failed: {e}"); + return None; + } + }; + let io: Box = if https { + match tls_connect(&host, tcp).await { + Some(tls) => Box::new(tls), + None => return None, } } else { - None - }; - - let io: Box = match exit_io { - Some(io) => io, - None => { - // Resolve the host over the tunnel (DoT — see dns), then dial that - // IP through the same tunnel so nothing (lookup or body) touches - // the clear. - let addr = dns::resolve(tunnel, &host, port).await?; - let tcp = match tunnel.tcp_connect(addr).await { - Ok(s) => s, - Err(e) => { - warn!("nym http: connect to {host} failed: {e}"); - return None; - } - }; - if https { - match tls_connect(&host, tcp).await { - Some(tls) => Box::new(tls), - None => return None, - } - } else { - Box::new(tcp) - } - } + Box::new(tcp) }; let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io)) @@ -226,35 +202,6 @@ async fn request_once( Some((status, bytes, location)) } -/// Try the scoped-exit egress for an HTTPS `host`: a MixnetStream to the -/// relay operator's exit ([`streamexit`]), then the SAME hostname-validated -/// [`tls_connect`] as the tunnel path — SNI = `host`, so the exit sees only -/// ciphertext. `None` (logged) on ANY failure, and the whole attempt is -/// bounded by the shared bootstrap cap — a dead exit costs seconds inside the -/// caller's [`HTTP_TIMEOUT`] budget, leaving room to fall back to the tunnel. -async fn exit_connect(host: &str, exit: &str) -> Option> { - let cap = nymproc::BOOTSTRAP_TIMEOUT; - let dial = async { - let stream = streamexit::open_stream(exit, cap) - .await - .map_err(|e| warn!("nym http: scoped exit for {host} unavailable: {e}")) - .ok()?; - let tls = tls_connect(host, stream).await?; - debug!("nym http: {host} riding its operator's scoped exit"); - Some(Box::new(tls) as Box) - }; - match tokio::time::timeout(cap, dial).await { - Ok(io) => io, - Err(_) => { - warn!( - "nym http: scoped exit dial for {host} exceeded {}s; falling back to the tunnel", - cap.as_secs() - ); - None - } - } -} - /// Everything hyper needs from the tunneled stream, boxable for the plain /// http / https split. trait Stream: AsyncRead + AsyncWrite + Send + Unpin {} diff --git a/src/nym/nymproc.rs b/src/nym/nymproc.rs index 6e927dc..a7ed575 100644 --- a/src/nym/nymproc.rs +++ b/src/nym/nymproc.rs @@ -23,12 +23,8 @@ //! re-selects, so there is no single-exit SPOF. Hostnames resolve via //! [`super::dns`] over DoT through the same tunnel, so nothing touches clearnet. //! -//! This is the FALLBACK / discovery-and-secondary-relay path. The MONEY-PATH -//! primary relay is reached over a SCOPED MixnetStream to a Floonet operator's -//! CO-LOCATED exit when the pool advertises one ([`crate::nostr::pool::PoolRelay::exit`]), -//! which needs no public DNS and no public IPR — see the streamexit egress -//! (design in ~/.claude/plans/floonet-nym-exit.md). That anchor+fallback split -//! is the "prefer our exit, never pin-only" rule at the transport level. +//! This is the wallet's ONLY mixnet path — every relay and HTTP dial rides +//! this tunnel. //! //! Should smolmix ever regress, the fallback design (SOCKS5 network requester //! + ordered exit failover) is specified in the plan, section G14. @@ -406,9 +402,7 @@ const MIN_EXIT_LIFETIME: Duration = Duration::from_secs(20); /// re-select. A healthy gateway+IPR bootstrap completes in ~4-7s; without this /// cap a DEAD first pick blocked for ~74s (measured) on the Nym SDK's own /// "listening for connection response" timeout before we even got to reselect. -/// A few seconds of patience, not a minute. Shared with the scoped-exit egress -/// ([`super::streamexit`]) as ITS dial cap, so both mixnet bootstraps fail -/// equally fast. +/// A few seconds of patience, not a minute. pub(crate) const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(20); /// Restore the pre-Build-98 watchdog (condemn on RELAY_GRACE of no-relay alone, diff --git a/src/nym/streamexit.rs b/src/nym/streamexit.rs index ece8ee6..3e7c565 100644 --- a/src/nym/streamexit.rs +++ b/src/nym/streamexit.rs @@ -12,218 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Scoped-MixnetStream egress — the MONEY-PATH ANCHOR. When the relay pool -//! advertises a relay operator's CO-LOCATED Nym exit -//! ([`crate::nostr::pool::PoolRelay::exit`]), the wallet dials that exit -//! directly over the mixnet with a [`MixnetStream`]; the exit pipes the bytes -//! to its ONE configured relay. No public DNS, no public IPR — the two flaky -//! dependencies of the fallback path are gone from the money path. The exit is -//! scoped (it forwards nowhere else), so the wallet writes nothing but the TLS -//! ClientHello: the dial sites run the SAME hostname-validated TLS (SNI = the -//! relay host) + websocket/HTTP wrap over this stream as over the smolmix -//! tunnel's TCP stream, and the exit sees only ciphertext. -//! -//! ANCHOR + FALLBACK, never pin-only: every failure here (bad address, client -//! bootstrap, stream open, timeout) just returns `Err`, and the dial sites -//! ([`super::transport`], [`super::request_once`]) fall through to the -//! public-IPR tunnel ([`super::nymproc`]) — losing the operator's exit never -//! locks the wallet out. Server side: the bundled `floonet-mixexit` binary -//! (design in ~/.claude/plans/floonet-nym-exit.md). - -use std::time::Duration; - -use log::{info, warn}; -use nym_sdk::mixnet::{MixnetClient, MixnetStream, Recipient}; -use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::sync::Mutex; - -/// Everything the TLS/websocket layer needs from the egress stream. -pub trait ExitStream: AsyncRead + AsyncWrite + Send + Unpin {} -impl ExitStream for T {} - -/// The boxed transport stream handed to the TLS/websocket layer — the same -/// seat the smolmix tunnel's TCP stream occupies on the fallback path. -pub type BoxedStream = Box; - -/// After the Open is SENT, wait this long before handing back a writable -/// stream. `open_stream` returns once the Open message leaves the client, NOT -/// once the exit has `accept()`ed and wired its inbound half. But the caller -/// speaks first (TLS ClientHello over a raw-pipe exit), so a write landing in -/// that gap is dropped and the handshake stalls into a fallback. One mixnet -/// round of slack lets the exit be listening before the first byte. -/// ponytail: fixed settle (measured: 0s always stalls, 3s is reliable). The -/// exit pipes raw bytes to its relay, so it can't inject an accept-ack for the -/// client to wait on; if mixnet jitter ever makes 3s flaky, raise it. -const STREAM_SETTLE: Duration = Duration::from_secs(3); - -/// Process-lifetime mixnet client for the scoped-exit egress, lazily connected -/// on first use (mirrors the tunnel singleton in [`super::nymproc`]). -/// Ephemeral in-memory identity, like the tunnel — a fresh mixnet identity per -/// run. Behind an async mutex because `open_stream` needs `&mut`; a dead -/// client (cancelled shutdown token or a failed open) is dropped so the next -/// dial reconnects fresh. -static CLIENT: Mutex> = Mutex::const_new(None); - -// NOTE ON FIRST-DIAL LATENCY: the exit rides a SECOND ephemeral MixnetClient -// (separate from the smolmix tunnel). On a cold app start both clients acquire -// Nym free-tier bandwidth, and the grants serialize — so the first dial that -// bootstraps this client can take ~a minute while the tunnel already has its -// grant. Measured: a startup pre-warm does NOT help — a second client warming -// in parallel just starves the tunnel/fallback for the same total, and slows -// the tunnel too. The real fix is sharing ONE mixnet client for tunnel + exit -// (larger change; tracked separately). Meanwhile the cost is one-time per cold -// start, the payment itself is fast once connected, and discovery/secondary -// relays + the fallback ride the tunnel, so availability is never blocked. - -/// Open a scoped MixnetStream to `exit` — a pool-advertised Nym address -/// (`.@`) of a relay operator's co-located exit. The -/// whole dial (client bootstrap when cold + stream open) is capped at -/// `min(timeout, BOOTSTRAP_TIMEOUT)` so a stuck bootstrap fails FAST into the -/// caller's public-IPR fallback. NOTE: `open_stream` is fire-and-forget on the -/// mixnet — a DEAD exit still hands back a stream, and its death surfaces at -/// the caller's (timeout-bounded) TLS handshake, which doubles as the -/// liveness probe: no ServerHello through the pipe → fall back. -pub async fn open_stream(exit: &str, timeout: Duration) -> Result { - let recipient: Recipient = exit - .trim() - .parse() - .map_err(|e| format!("invalid exit address: {e}"))?; - let cap = timeout.min(super::nymproc::BOOTSTRAP_TIMEOUT); - let stream = match tokio::time::timeout(cap, open(recipient)).await { - Ok(result) => result?, - Err(_) => return Err(format!("exit dial exceeded {}s", cap.as_secs())), - }; - // Let the exit accept() + wire its inbound half before the caller writes. - tokio::time::sleep(STREAM_SETTLE).await; - Ok(Box::new(stream) as BoxedStream) -} - -/// Ensure the shared client is connected, then open a stream on it. -async fn open(recipient: Recipient) -> Result { - let mut guard = CLIENT.lock().await; - // A dead client (gateway dropped, hosting runtime gone) is discarded and - // rebuilt — the auto-reconnect-on-drop rule. - if guard - .as_ref() - .is_some_and(|c| c.cancellation_token().is_cancelled()) - { - warn!("nym: streamexit client died; reconnecting"); - *guard = None; - } - if guard.is_none() { - let started = std::time::Instant::now(); - let client = MixnetClient::connect_new() - .await - .map_err(|e| format!("mixnet client bootstrap failed: {e}"))?; - info!( - "[timing] nym: streamexit client CONNECTED in {}ms", - started.elapsed().as_millis() - ); - *guard = Some(client); - } - let client = guard.as_mut().expect("client ensured above"); - match client.open_stream(recipient, None).await { - Ok(stream) => Ok(stream), - Err(e) => { - // `open_stream` fails only LOCALLY (the client's input channel) — - // it never waits on the peer — so an error means the client itself - // is broken, not the exit. Drop it; the next dial reconnects. - *guard = None; - Err(format!("open_stream failed: {e}")) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn bad_exit_address_fails_fast_without_touching_the_mixnet() { - // The address parse runs BEFORE any client bootstrap, so garbage from - // a hostile pool costs nothing and degrades to the fallback path. - let err = open_stream("not-a-recipient", Duration::from_secs(5)) - .await - .err() - .expect("garbage address must fail"); - assert!(err.contains("invalid exit address"), "got: {err}"); - } - - /// LIVE end-to-end smoke test of the money path against the DEPLOYED - /// floonet-mixexit (.8): dial the pinned pool's `exit` for relay.goblin.st - /// over the mixnet with the real [`open_stream`], run the SAME - /// hostname-validated TLS + websocket wrap the wallet uses - /// ([`super::super::transport`]), then send a nostr REQ and require the - /// relay to answer (EVENT/EOSE). Proves mixnet -> exit -> relay:443 -> - /// nostr actually carries traffic. Ignored (needs network + a cold mixnet - /// bootstrap). Run: - /// cargo test --lib nym::streamexit::tests::live_exit_roundtrip -- --ignored --nocapture - #[tokio::test] - #[ignore] - async fn live_exit_roundtrip() { - use futures::{SinkExt, StreamExt}; - use tokio_tungstenite::tungstenite::Message; - - // The app installs this at startup (src/lib.rs); an isolated test must - // too, or rustls 0.23 can't pick a provider for the TLS handshake. - let _ = rustls::crypto::ring::default_provider().install_default(); - - let exit = crate::nostr::pool::load() - .exit_for("wss://relay.goblin.st") - .expect("pinned pool advertises the relay.goblin.st exit"); - println!("dialing scoped exit {exit}"); - - // A cold ephemeral mixnet bootstrap can exceed the per-dial cap; the - // real wallet just falls back and retries, so retry until one dial wins. - let mut stream = None; - for attempt in 1..=6 { - let t = std::time::Instant::now(); - match open_stream(&exit, Duration::from_secs(90)).await { - Ok(s) => { - println!( - "open_stream OK on attempt {attempt} in {}ms", - t.elapsed().as_millis() - ); - stream = Some(s); - break; - } - Err(e) => println!( - "attempt {attempt} failed in {}ms: {e}", - t.elapsed().as_millis() - ), - } - } - let stream = stream.expect("exit stream opened within retries"); - - let url = "wss://relay.goblin.st"; - let (mut ws, _resp) = tokio::time::timeout( - Duration::from_secs(45), - tokio_tungstenite::client_async_tls(url, stream), - ) - .await - .expect("TLS+ws handshake timed out (dead exit?)") - .expect("TLS+ws handshake through exit failed"); - println!("TLS+ws handshake through .8 exit OK"); - - ws.send(Message::Text( - r#"["REQ","smoke",{"kinds":[1],"limit":1}]"#.into(), - )) - .await - .expect("send REQ"); - - let reply = tokio::time::timeout(Duration::from_secs(30), ws.next()) - .await - .expect("relay reply timed out") - .expect("ws stream closed early") - .expect("ws frame error"); - let txt = match reply { - Message::Text(t) => t.to_string(), - other => format!("{other:?}"), - }; - println!("relay answered through exit: {txt}"); - assert!( - txt.contains("EVENT") || txt.contains("EOSE"), - "unexpected relay reply: {txt}" - ); - } -} +// removed: the scoped Nym exit egress was disabled (the pool no longer +// advertises any exit); the wallet's only mixnet path is the smolmix tunnel. +// This module is unreferenced and kept only as an empty placeholder. diff --git a/src/nym/transport.rs b/src/nym/transport.rs index 93e7df0..9270207 100644 --- a/src/nym/transport.rs +++ b/src/nym/transport.rs @@ -13,16 +13,10 @@ // limitations under the License. //! WebSocket transport for the Nostr relay pool routed through the Nym -//! mixnet, with TWO egresses picked per relay. ANCHOR: a relay whose pool -//! entry advertises its operator's co-located scoped exit -//! ([`crate::nostr::pool::PoolRelay::exit`]) is dialed over a MixnetStream -//! straight to that exit ([`super::streamexit`]) — no DNS, no public IPR. -//! FALLBACK (and every relay without an exit): Goblin's in-process smolmix -//! tunnel — the relay host is resolved by [`super::dns`], the TCP stream is -//! opened via `tunnel.tcp_connect`. Either way the SAME TLS (rustls, webpki -//! roots) + websocket handshake runs over the mixnet-carried stream, so the -//! payload + in-flight destination never touch the clear, and an exit failure -//! only ever falls back — never a lockout. +//! mixnet: Goblin's in-process smolmix tunnel — the relay host is resolved by +//! [`super::dns`], the TCP stream is opened via `tunnel.tcp_connect`. The TLS +//! (rustls, webpki roots) + websocket handshake runs over the mixnet-carried +//! stream, so the payload + in-flight destination never touch the clear. use std::fmt; use std::pin::Pin; @@ -78,34 +72,6 @@ impl WebSocketTransport for NymWebSocketTransport { _ => 443, }); - // MONEY-PATH ANCHOR: when the pool advertises this relay - // operator's co-located scoped Nym exit, dial THROUGH it — a - // MixnetStream straight to the exit (which pipes to its one - // relay), no public DNS, no public IPR, no tunnel dependency. The - // TLS + websocket wrap inside is byte-for-byte the tunnel path's - // (same `client_async_tls`, SNI = the relay host), so the exit - // sees only ciphertext. ANY failure — bootstrap, open, handshake, - // timeout — falls through to the public-IPR tunnel dial below: - // anchor + fallback, never pin-only. - if let Some(exit) = crate::nostr::pool::load().exit_for(url.as_str()) { - let t_exit = std::time::Instant::now(); - match exit_connect(url, &exit, timeout).await { - Ok(parts) => { - log::info!( - "[timing] nym: relay {host} CONNECTED via scoped exit — \ - stream+tls+ws {}ms", - t_exit.elapsed().as_millis() - ); - return Ok(parts); - } - Err(e) => log::warn!( - "nym: scoped exit dial for {host} failed after {}ms ({e}); \ - falling back to the public-IPR tunnel", - t_exit.elapsed().as_millis() - ), - } - } - // The shared mixnet tunnel (lazy-started at app launch). let tunnel = crate::nym::nymproc::wait_for_tunnel(timeout) .await @@ -150,33 +116,7 @@ impl WebSocketTransport for NymWebSocketTransport { } } -/// Dial `url` through the relay operator's scoped Nym exit `exit`: a -/// MixnetStream to the exit (which pipes to its one configured relay), then -/// the SAME hostname-validated TLS + websocket handshake as the tunnel path. -/// The handshake doubles as the exit liveness probe — `open_stream` is -/// fire-and-forget, so a dead exit surfaces here as a (bounded) timeout and -/// the caller falls back. -async fn exit_connect( - url: &Url, - exit: &str, - timeout: Duration, -) -> Result<(WebSocketSink, WebSocketStream), TransportError> { - let stream = crate::nym::streamexit::open_stream(exit, timeout) - .await - .map_err(terr)?; - let (ws, _response) = tokio::time::timeout( - timeout, - tokio_tungstenite::client_async_tls(url.as_str(), stream), - ) - .await - .map_err(|_| terr("websocket handshake timeout (exit stream)"))? - .map_err(|e| terr(format!("websocket handshake failed: {e}")))?; - Ok(split_ws(ws)) -} - -/// Split a websocket into the pool's boxed sink/stream halves — shared by the -/// scoped-exit and tunnel dial paths, so everything above the byte transport -/// is identical whichever egress carried the connection. +/// Split a websocket into the pool's boxed sink/stream halves. fn split_ws(ws: tokio_tungstenite::WebSocketStream) -> (WebSocketSink, WebSocketStream) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,