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:
+1
-1
@@ -15,7 +15,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name="grim"
|
name="grim"
|
||||||
crate-type = ["rlib"]
|
crate-type = ["cdylib","rlib"]
|
||||||
|
|
||||||
# Desktop/CI release binaries ship stripped of debug symbols — the nym + nostr +
|
# 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
|
# grin tree leaves a large symbol table that's dead weight for users (~16 MB on
|
||||||
|
|||||||
@@ -91,18 +91,6 @@ pub struct PoolRelay {
|
|||||||
/// Last-vetted date; presence marks the entry as vetted.
|
/// Last-vetted date; presence marks the entry as vetted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vetted: Option<String>,
|
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 {
|
impl PoolRelay {
|
||||||
@@ -149,36 +137,6 @@ impl RelayPool {
|
|||||||
.map(|r| r.url.clone())
|
.map(|r| r.url.clone())
|
||||||
.collect()
|
.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.
|
/// Disk path of the cached pool file.
|
||||||
@@ -366,48 +324,6 @@ mod tests {
|
|||||||
assert!(disc.contains(&"wss://indexer.coracle.social".to_string()));
|
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]
|
#[test]
|
||||||
fn pool_validation_rejects_bad_documents() {
|
fn pool_validation_rejects_bad_documents() {
|
||||||
assert!(RelayPool::parse("not json").is_none());
|
assert!(RelayPool::parse("not json").is_none());
|
||||||
@@ -475,7 +391,6 @@ mod tests {
|
|||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
roles: vec!["dm".to_string()],
|
roles: vec!["dm".to_string()],
|
||||||
vetted: vetted.then(|| "2026-07-01".to_string()),
|
vetted: vetted.then(|| "2026-07-01".to_string()),
|
||||||
exit: None,
|
|
||||||
};
|
};
|
||||||
vec![
|
vec![
|
||||||
mk("wss://a.example", false),
|
mk("wss://a.example", false),
|
||||||
@@ -500,7 +415,6 @@ mod tests {
|
|||||||
url: "wss://relay.goblin.st".to_string(),
|
url: "wss://relay.goblin.st".to_string(),
|
||||||
roles: vec!["dm".to_string()],
|
roles: vec!["dm".to_string()],
|
||||||
vetted: Some("2026-07-01".to_string()),
|
vetted: Some("2026-07-01".to_string()),
|
||||||
exit: None,
|
|
||||||
});
|
});
|
||||||
let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0);
|
let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0);
|
||||||
assert_eq!(order.len(), 4);
|
assert_eq!(order.len(), 4);
|
||||||
|
|||||||
+22
-75
@@ -14,16 +14,12 @@
|
|||||||
|
|
||||||
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
|
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
|
||||||
//! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet:
|
//! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet:
|
||||||
//! by default one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an
|
//! one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an auto-selected
|
||||||
//! auto-selected public IPR exit, so neither the payload nor the
|
//! public IPR exit, so neither the payload nor the destination-in-flight ever
|
||||||
//! destination-in-flight ever touches the clearnet. Hostnames resolve through
|
//! touches the clearnet. Hostnames resolve through the same tunnel too
|
||||||
//! the same tunnel too ([`dns`], DoT — DNS-over-TLS), so nothing goes
|
//! ([`dns`], DoT — DNS-over-TLS), so nothing goes clearnet. The mixnet breaks
|
||||||
//! clearnet. MONEY-PATH ANCHOR: a host whose relay advertises a co-located
|
//! the sender↔receiver timing correlation that Mimblewimble's interactive
|
||||||
//! scoped exit in the pool is instead dialed over a MixnetStream straight to
|
//! slate exchange otherwise leaks at the network layer.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! DNS reliability was the one weak spot: the original mix-dns sent UDP over the
|
//! 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
|
//! mixnet, and mixnet UDP loses packets — resolves stalled on multi-second
|
||||||
@@ -35,7 +31,6 @@
|
|||||||
|
|
||||||
pub mod dns;
|
pub mod dns;
|
||||||
pub mod nymproc;
|
pub mod nymproc;
|
||||||
pub mod streamexit;
|
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -137,43 +132,24 @@ async fn request_once(
|
|||||||
let https = url.scheme() == "https";
|
let https = url.scheme() == "https";
|
||||||
let port = url.port().unwrap_or(if https { 443 } else { 80 });
|
let port = url.port().unwrap_or(if https { 443 } else { 80 });
|
||||||
|
|
||||||
// MONEY-PATH ANCHOR fork: HTTPS to a host whose relay advertises a
|
// Resolve the host over the tunnel (DoT — see dns), then dial that
|
||||||
// co-located scoped Nym exit (its NIP-11 probe, in practice) rides a
|
// IP through the same tunnel so nothing (lookup or body) touches
|
||||||
// MixnetStream to that exit instead of the tunnel — no public DNS, no
|
// the clear.
|
||||||
// public IPR. Failure just falls through to the tunnel path below (anchor
|
let addr = dns::resolve(tunnel, &host, port).await?;
|
||||||
// + fallback, never pin-only).
|
let tcp = match tunnel.tcp_connect(addr).await {
|
||||||
let exit_io = if https {
|
Ok(s) => s,
|
||||||
match crate::nostr::pool::load().exit_for_host(&host) {
|
Err(e) => {
|
||||||
Some(exit) => exit_connect(&host, &exit).await,
|
warn!("nym http: connect to {host} failed: {e}");
|
||||||
None => None,
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let io: Box<dyn Stream> = if https {
|
||||||
|
match tls_connect(&host, tcp).await {
|
||||||
|
Some(tls) => Box::new(tls),
|
||||||
|
None => return None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
Box::new(tcp)
|
||||||
};
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
|
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
|
||||||
@@ -226,35 +202,6 @@ async fn request_once(
|
|||||||
Some((status, bytes, location))
|
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
|
/// Everything hyper needs from the tunneled stream, boxable for the plain
|
||||||
/// http / https split.
|
/// http / https split.
|
||||||
trait Stream: AsyncRead + AsyncWrite + Send + Unpin {}
|
trait Stream: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||||
|
|||||||
+3
-9
@@ -23,12 +23,8 @@
|
|||||||
//! re-selects, so there is no single-exit SPOF. Hostnames resolve via
|
//! re-selects, so there is no single-exit SPOF. Hostnames resolve via
|
||||||
//! [`super::dns`] over DoT through the same tunnel, so nothing touches clearnet.
|
//! [`super::dns`] over DoT through the same tunnel, so nothing touches clearnet.
|
||||||
//!
|
//!
|
||||||
//! This is the FALLBACK / discovery-and-secondary-relay path. The MONEY-PATH
|
//! This is the wallet's ONLY mixnet path — every relay and HTTP dial rides
|
||||||
//! primary relay is reached over a SCOPED MixnetStream to a Floonet operator's
|
//! this tunnel.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Should smolmix ever regress, the fallback design (SOCKS5 network requester
|
//! Should smolmix ever regress, the fallback design (SOCKS5 network requester
|
||||||
//! + ordered exit failover) is specified in the plan, section G14.
|
//! + 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
|
/// 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
|
/// 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.
|
/// "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
|
/// A few seconds of patience, not a minute.
|
||||||
/// ([`super::streamexit`]) as ITS dial cap, so both mixnet bootstraps fail
|
|
||||||
/// equally fast.
|
|
||||||
pub(crate) const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(20);
|
pub(crate) const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
/// Restore the pre-Build-98 watchdog (condemn on RELAY_GRACE of no-relay alone,
|
/// Restore the pre-Build-98 watchdog (condemn on RELAY_GRACE of no-relay alone,
|
||||||
|
|||||||
+3
-215
@@ -12,218 +12,6 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//! Scoped-MixnetStream egress — the MONEY-PATH ANCHOR. When the relay pool
|
// removed: the scoped Nym exit egress was disabled (the pool no longer
|
||||||
//! advertises a relay operator's CO-LOCATED Nym exit
|
// advertises any exit); the wallet's only mixnet path is the smolmix tunnel.
|
||||||
//! ([`crate::nostr::pool::PoolRelay::exit`]), the wallet dials that exit
|
// This module is unreferenced and kept only as an empty placeholder.
|
||||||
//! 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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-65
@@ -13,16 +13,10 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//! WebSocket transport for the Nostr relay pool routed through the Nym
|
//! WebSocket transport for the Nostr relay pool routed through the Nym
|
||||||
//! mixnet, with TWO egresses picked per relay. ANCHOR: a relay whose pool
|
//! mixnet: Goblin's in-process smolmix tunnel — the relay host is resolved by
|
||||||
//! entry advertises its operator's co-located scoped exit
|
//! [`super::dns`], the TCP stream is opened via `tunnel.tcp_connect`. The TLS
|
||||||
//! ([`crate::nostr::pool::PoolRelay::exit`]) is dialed over a MixnetStream
|
//! (rustls, webpki roots) + websocket handshake runs over the mixnet-carried
|
||||||
//! straight to that exit ([`super::streamexit`]) — no DNS, no public IPR.
|
//! stream, so the payload + in-flight destination never touch the clear.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -78,34 +72,6 @@ impl WebSocketTransport for NymWebSocketTransport {
|
|||||||
_ => 443,
|
_ => 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).
|
// The shared mixnet tunnel (lazy-started at app launch).
|
||||||
let tunnel = crate::nym::nymproc::wait_for_tunnel(timeout)
|
let tunnel = crate::nym::nymproc::wait_for_tunnel(timeout)
|
||||||
.await
|
.await
|
||||||
@@ -150,33 +116,7 @@ impl WebSocketTransport for NymWebSocketTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dial `url` through the relay operator's scoped Nym exit `exit`: a
|
/// Split a websocket into the pool's boxed sink/stream halves.
|
||||||
/// 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.
|
|
||||||
fn split_ws<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
|
fn split_ws<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
|
||||||
where
|
where
|
||||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
|
||||||
|
|||||||
Reference in New Issue
Block a user