1
0
forked from GRIN/grim

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.
This commit is contained in:
Goblin
2026-07-02 10:06:50 -04:00
parent 12f78f3af7
commit 337220299f
6 changed files with 34 additions and 451 deletions
+1 -1
View File
@@ -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
-86
View File
@@ -91,18 +91,6 @@ pub struct PoolRelay {
/// Last-vetted date; presence marks the entry as vetted.
#[serde(default)]
pub vetted: Option<String>,
/// 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` (`<client>.<enc>@<gateway>`) 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<String>,
}
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<String> {
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<String> {
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);
+22 -75
View File
@@ -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<dyn Stream> = if https {
match tls_connect(&host, tcp).await {
Some(tls) => Box::new(tls),
None => return None,
}
} else {
None
};
let io: Box<dyn Stream> = 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<Box<dyn Stream>> {
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<dyn Stream>)
};
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 {}
+3 -9
View File
@@ -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,
+3 -215
View File
@@ -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<T: AsyncRead + AsyncWrite + Send + Unpin> 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<dyn ExitStream>;
/// 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<Option<MixnetClient>> = 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
/// (`<client>.<enc>@<gateway>`) 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<BoxedStream, String> {
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<MixnetStream, String> {
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.
+5 -65
View File
@@ -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<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,