mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-10 08:54:11 +00:00
Goblin Build 134 - Tor delivery fixes (drop the flapping onion)
build133 shipped the Tor transport but two delivery bugs remained; this fixes both so payments resolve through confirmations again. - Drop the pinned relay .onion. It was a single fragile hidden-service hop shared by every wallet; under load it flapped (WebSocket 1006), and when it dropped mid-handshake a payment stalled after the first gift-wrap -- you'd get the incoming alert but the money never confirmed. Every relay is now reached over the Tor exit to its clearnet host: your IP still stays hidden from the relay, with no fragile onion hop to wedge. The has-onion gate that rejected an onion-less relay list is removed, and the pinned candidate pool carries no onion/exit fields. - Tor-friendly relay set. Defaults/fallbacks had included relays that refuse Tor-exit connections (damus, nos.lol); wallets that fell back to them couldn't send at all. Defaults and the pinned pool are now relay.floonet.dev, relay.0xchat.com, offchain.pub. - confirm-before-sent no longer false-fails across a relay reconnect (a confirmed-received wrap is treated as sent-pending instead of re-dispatched, which had duplicated wraps and hung the spinner); a v3 gift-wrap unwrap failure no longer silently drops the message. Validated over Tor with two funded wallets on two independent mainnet nodes: connect in seconds, send to finalize in 8s.
This commit is contained in:
+86
-37
@@ -59,12 +59,12 @@ const LOOKBACK_SECS: i64 = 3 * 86_400;
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Send dispatch timeout.
|
||||
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
|
||||
/// Money-path safety: a payment/control DM is only reported "sent" once a relay
|
||||
/// is confirmed to actually hold the gift wrap. A transport-write success is NOT
|
||||
/// proof of delivery — over the transport a wrap can trail its local "sent" by
|
||||
/// seconds (transport buffering / a slow relay), so reporting on the write alone
|
||||
/// silently loses payments. Total budget to confirm via read-back before
|
||||
/// surfacing failure to the caller.
|
||||
/// Money-path safety: total budget to read-back-confirm a dispatched wrap on a
|
||||
/// relay the recipient reads (a positive delivery proof — a transport-write
|
||||
/// success alone is not). The confirm retries across transient transport drops
|
||||
/// within this budget; on exhaustion the send is treated as sent-PENDING (the tx
|
||||
/// waits for S2 / expiry), NOT a hard failure — see the confirm loop in
|
||||
/// `dispatch_dm` for why a hard failure here would trigger duplicate re-dispatch.
|
||||
const CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Per-attempt read-back timeout while confirming (short, so one dead relay
|
||||
/// doesn't consume the whole confirm budget in a single poll).
|
||||
@@ -614,30 +614,52 @@ impl NostrService {
|
||||
.map_err(|e| format!("send failed: {e}"))?;
|
||||
let event_id = res.val;
|
||||
|
||||
// SILENT-LOSS GUARD (money-path safety). `send_*_to` returns success the
|
||||
// moment the gift wrap is written to the transport sink — NOT when a relay
|
||||
// has actually stored it. Over the transport a wrap can trail its local
|
||||
// "sent" by seconds (transport buffering / a slow relay), so a bare success
|
||||
// is a FALSE "sent" that silently loses the payment. Require a genuine
|
||||
// read-back: poll the target relays for the event id (it may still be in
|
||||
// flight right after send) until one confirms it holds the wrap, or the
|
||||
// CONFIRM_TIMEOUT budget is spent — then surface failure so the caller
|
||||
// retries / falls back instead of dropping the payment.
|
||||
let confirm_filter = Filter::new().id(event_id).limit(1);
|
||||
// DELIVERY CONFIRM (money-path safety), reconnect-resilient. `send_*_to`
|
||||
// returned success the moment the wrap was accepted for delivery to the
|
||||
// relays — that IS write-level evidence, but not proof a relay the RECIPIENT
|
||||
// reads has stored it. Confirm the way the recipient's inbox retrieves it:
|
||||
// query {kinds:[1059], "#p":[receiver]} pinned to THIS wrap's id, over the
|
||||
// SAME target set — which now always includes our own advertised relays
|
||||
// (the shared-relay floor the recipient also reads; see `send_targets`).
|
||||
// First-event-wins via `stream_events_from` returns the instant ANY
|
||||
// targeted relay serves the wrap, so the spinner clears as fast as it lands.
|
||||
// The loop retries across transient transport drops within the budget
|
||||
// (arti rebuilds circuits during the CONFIRM_GAP sleeps), so a flapping
|
||||
// onion doesn't defeat a wrap that actually landed.
|
||||
//
|
||||
// Two outcomes, NEVER a hard failure here (we already hold write-evidence):
|
||||
// * confirmed -> Ok, report Sent (strongest evidence).
|
||||
// * unconfirmed within the budget -> Ok as sent-PENDING, logged. Over a
|
||||
// flapping onion the read-back can't complete even though the write
|
||||
// landed; a hard error would mark the tx SendFailed, which `reconcile`
|
||||
// re-dispatches -> DUPLICATE wraps + a stuck "sending" spinner. Instead
|
||||
// the tx moves to AwaitingS2 (which `reconcile` never re-dispatches) and
|
||||
// the normal S2-wait / expiry path resolves it. Only a genuine send
|
||||
// FAILURE (the `send_*_to` error above, BEFORE this loop) ever
|
||||
// re-dispatches — so we never claim Sent with zero evidence.
|
||||
use futures::StreamExt;
|
||||
let confirm_filter = Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(receiver)
|
||||
.id(event_id)
|
||||
.limit(1);
|
||||
let confirm_deadline = tokio::time::Instant::now() + CONFIRM_TIMEOUT;
|
||||
loop {
|
||||
if let Ok(events) = client
|
||||
.fetch_events_from(&urls, confirm_filter.clone(), CONFIRM_POLL)
|
||||
.await && events.first().is_some()
|
||||
if let Ok(mut stream) = client
|
||||
.stream_events_from(urls.clone(), confirm_filter.clone(), CONFIRM_POLL)
|
||||
.await && stream.next().await.is_some()
|
||||
{
|
||||
return Ok(event_id.to_hex());
|
||||
}
|
||||
if tokio::time::Instant::now() >= confirm_deadline {
|
||||
return Err(format!(
|
||||
"payment not confirmed on any relay within {}s — the transport \
|
||||
reported it sent but no relay holds it yet; treat as UNSENT and retry",
|
||||
warn!(
|
||||
"nostr: wrap {} dispatched but not read-back-confirmed within {}s \
|
||||
(likely a transient transport drop); treating as sent-pending — \
|
||||
tx waits for S2 / expiry, NOT re-dispatched",
|
||||
event_id.to_hex(),
|
||||
CONFIRM_TIMEOUT.as_secs()
|
||||
));
|
||||
);
|
||||
return Ok(event_id.to_hex());
|
||||
}
|
||||
tokio::time::sleep(CONFIRM_GAP).await;
|
||||
}
|
||||
@@ -645,27 +667,42 @@ impl NostrService {
|
||||
|
||||
/// Publish targets for one DM plus the negotiated NIP-44 v3 capability:
|
||||
/// the recipient's advertised 10050 inbox (capped at 3) when they publish
|
||||
/// one; otherwise the pragmatic fallback of nprofile relay hints plus our
|
||||
/// own relay set (most Goblin peers share the Goblin relay). No extra
|
||||
/// targets beyond that — wider fan-out adds metadata surface, not
|
||||
/// deliverability. `true` means the recipient's 10050 `encryption` tag
|
||||
/// advertises `nip44_v3`; no tag (or no 10050 at all) = v2 only.
|
||||
/// one, PLUS the nprofile relay hints, ALWAYS unioned with our OWN advertised
|
||||
/// set. `true` means the recipient's 10050 `encryption` tag advertises
|
||||
/// `nip44_v3`; no tag (or no 10050 at all) = v2 only.
|
||||
///
|
||||
/// MONEY-PATH SAFETY: we must NEVER return a target set that excludes our own
|
||||
/// relays. Our advertised set always begins with the shared relay floor
|
||||
/// (`relay.floonet.dev`, `DEFAULT_RELAYS[0]`, pinned first by
|
||||
/// `ensure_advertised_set`), and every Goblin peer's inbox subscription
|
||||
/// (`{kinds:[1059], "#p":[them]}`, see the service loop) likewise reads that
|
||||
/// same shared relay. The prior code early-returned ONLY the recipient's
|
||||
/// cached 10050 set: if that cache was stale or hint-seeded and missed the
|
||||
/// shared relay, the wrap was published solely to relays the recipient never
|
||||
/// reads — delivered nowhere while the sender saw success. Unioning our own
|
||||
/// set guarantees the wrap always lands on a relay both parties read, even
|
||||
/// when the recipient's cached relays are wrong.
|
||||
async fn send_targets(
|
||||
&self,
|
||||
client: &Client,
|
||||
receiver: &PublicKey,
|
||||
relay_hints: &[String],
|
||||
) -> (Vec<String>, bool) {
|
||||
let (urls, v3) = self.fetch_dm_relays(client, receiver).await;
|
||||
if !urls.is_empty() {
|
||||
return (urls, v3);
|
||||
}
|
||||
let (recipient_relays, v3) = self.fetch_dm_relays(client, receiver).await;
|
||||
let mut urls: Vec<String> = vec![];
|
||||
for r in relay_hints {
|
||||
if !urls.contains(r) {
|
||||
urls.push(r.clone());
|
||||
// The recipient's own advertised inbox first (best delivery target when
|
||||
// fresh), then any nprofile relay hints...
|
||||
for r in recipient_relays
|
||||
.into_iter()
|
||||
.chain(relay_hints.iter().cloned())
|
||||
{
|
||||
if !urls.contains(&r) {
|
||||
urls.push(r);
|
||||
}
|
||||
}
|
||||
// ...and ALWAYS our own advertised set (the shared-relay floor). This is
|
||||
// the load-bearing union: it never lets a stale recipient cache exclude
|
||||
// the relay both parties actually read.
|
||||
for r in self.relays() {
|
||||
if !urls.contains(&r) {
|
||||
urls.push(r);
|
||||
@@ -1506,8 +1543,20 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, event: Event) {
|
||||
// nostr-sdk path, 0x03 = the nip44 crate (G4); anything else errors cleanly.
|
||||
let unwrapped = match wrapv3::unwrap(&svc.keys, &event).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
Err(e) => {
|
||||
// A gift wrap that reached here is p-tagged to US (both the catch-up
|
||||
// fetch and the live subscription filter name our pubkey), so a decrypt
|
||||
// failure is a wrap ADDRESSED to us that we could not open — most often a
|
||||
// NIP-44 v2/v3 negotiation mismatch or a decrypt bug, i.e. potentially a
|
||||
// real incoming payment. Do NOT silently drop it: surface the failure,
|
||||
// and do NOT mark it processed, so a corrected build can re-attempt the
|
||||
// unwrap on the next catch-up instead of the dedup cache eating the
|
||||
// payment forever. Total unwrap work stays bounded by the global decrypt
|
||||
// ceiling checked above, which is the designed spam guard.
|
||||
warn!(
|
||||
"nostr: gift wrap {wrap_id} addressed to us failed to unwrap: {e}; \
|
||||
leaving unprocessed for retry"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
+21
-83
@@ -62,22 +62,13 @@ const MIN_BACKDATE_SECS: u64 = 172_800;
|
||||
/// offline behave exactly like a fresh fetch.
|
||||
const PINNED_POOL: &str = r#"{
|
||||
"version": 1,
|
||||
"updated": "2026-07-02",
|
||||
"notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. The optional per-relay 'exit' is that operator's co-located scoped mixnet exit (Recipient address): a MixnetStream the wallet dials directly to reach the relay with no public DNS and no public IPR — the fast money path.",
|
||||
"updated": "2026-07-04",
|
||||
"notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. Every relay is reached over a Tor exit to its clearnet host, so the wallet's IP stays hidden behind Tor.",
|
||||
"min_message_length": 131072,
|
||||
"relays": [
|
||||
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-02", "exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe", "onion": "m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion" },
|
||||
{ "url": "wss://relay.primal.net", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.0xchat.com", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.snort.social", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://nostr.mom", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://nostr.oxtr.dev", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.nostr.net", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://purplepag.es", "roles": ["discovery"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://indexer.coracle.social", "roles": ["discovery"], "vetted": "2026-07-01" }
|
||||
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-04" },
|
||||
{ "url": "wss://relay.0xchat.com", "roles": ["dm", "discovery"], "vetted": "2026-07-04" },
|
||||
{ "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-04" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
@@ -103,16 +94,6 @@ pub struct PoolRelay {
|
||||
/// it is meant to replace.
|
||||
#[serde(default)]
|
||||
pub exit: Option<String>,
|
||||
/// This relay's pinned `.onion` address (`<host>.onion`, optional `:port`),
|
||||
/// when its operator fronts the relay with a Tor onion service. The wallet
|
||||
/// dials this over embedded Tor and speaks PLAIN websocket to it (the onion
|
||||
/// connection is already encrypted+authenticated end to end). Absent → the
|
||||
/// relay is reached over a Tor exit to its clearnet host instead. Added beside
|
||||
/// `exit` the same tolerant way (no `deny_unknown_fields`, `version` stays 1),
|
||||
/// so OLDER builds simply ignore it — no schema break, no flag day. Carried in
|
||||
/// the pinned pool so the money-path relay's onion bootstraps OFFLINE.
|
||||
#[serde(default)]
|
||||
pub onion: Option<String>,
|
||||
}
|
||||
|
||||
impl PoolRelay {
|
||||
@@ -199,27 +180,6 @@ impl RelayPool {
|
||||
.iter()
|
||||
.any(|r| r.exit.as_deref().is_some_and(|e| !e.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// The pinned `.onion` for `url`, if the pool advertises one (url compared
|
||||
/// modulo a trailing slash). `None` → reach the relay over a Tor exit to its
|
||||
/// clearnet host. This is how the wallet learns the money-path relay's onion
|
||||
/// (see [`PoolRelay::onion`]).
|
||||
pub fn onion_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.onion.clone())
|
||||
.filter(|o| !o.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Whether ANY relay in the pool pins an `.onion`. Used to prefer a pool that
|
||||
/// carries the money-path onion (see [`load`]).
|
||||
pub fn has_onion(&self) -> bool {
|
||||
self.relays
|
||||
.iter()
|
||||
.any(|r| r.onion.as_deref().is_some_and(|o| !o.trim().is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk path of the cached pool file.
|
||||
@@ -233,12 +193,6 @@ pub fn load() -> RelayPool {
|
||||
std::fs::read_to_string(cache_path())
|
||||
.ok()
|
||||
.and_then(|raw| RelayPool::parse(&raw))
|
||||
// A cache written by a pre-Tor build parses fine but hides the onion
|
||||
// money path (and the current primary relay) for up to CACHE_MAX_AGE_SECS
|
||||
// after an app update — relay connects then have no onion to dial for days.
|
||||
// The pinned pool is newer than any onion-less file, so prefer it until the
|
||||
// next gist refresh.
|
||||
.filter(RelayPool::has_onion)
|
||||
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
|
||||
}
|
||||
|
||||
@@ -256,14 +210,7 @@ pub async fn refresh_if_stale() {
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.map(|age| age.as_secs() < CACHE_MAX_AGE_SECS)
|
||||
.unwrap_or(false)
|
||||
// An onion-less cache predates the current pool shape (see `load`,
|
||||
// which already ignores it) — replace it now instead of serving the
|
||||
// pinned fallback for the rest of the file's 7 days.
|
||||
&& std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|raw| RelayPool::parse(&raw))
|
||||
.is_some_and(|p| p.has_onion());
|
||||
.unwrap_or(false);
|
||||
if fresh {
|
||||
return;
|
||||
}
|
||||
@@ -411,37 +358,30 @@ mod tests {
|
||||
let pool = RelayPool::parse(PINNED_POOL).expect("pinned pool must parse");
|
||||
assert_eq!(pool.version, 1);
|
||||
assert_eq!(pool.min_message_length, MIN_MESSAGE_LENGTH);
|
||||
assert_eq!(pool.relays.len(), 12);
|
||||
// Three Tor-friendly relays matching the live gist; no relay pins an onion
|
||||
// any more (the onion money path was dropped in build134 — every relay is
|
||||
// reached over a Tor exit to its clearnet host).
|
||||
assert_eq!(pool.relays.len(), 3);
|
||||
let dm = pool.dm_relays();
|
||||
assert_eq!(dm.len(), 10);
|
||||
assert_eq!(dm.len(), 3);
|
||||
assert!(dm.iter().any(|r| r.url == "wss://relay.floonet.dev"));
|
||||
assert!(dm.iter().all(|r| r.vetted.is_some()));
|
||||
// The money-path relay pins its .onion so the Tor transport bootstraps
|
||||
// OFFLINE, before any network; every other relay is onion-less (reached
|
||||
// over a Tor exit).
|
||||
assert!(pool.has_onion());
|
||||
assert_eq!(
|
||||
pool.onion_for("wss://relay.floonet.dev"),
|
||||
Some("m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion".to_string())
|
||||
);
|
||||
assert!(pool.onion_for("wss://nos.lol").is_none());
|
||||
let disc = pool.discovery_relays();
|
||||
// relay.floonet.dev carries both roles; the two indexers
|
||||
// are discovery-only.
|
||||
assert_eq!(disc.len(), 3);
|
||||
assert!(disc.contains(&"wss://purplepag.es".to_string()));
|
||||
assert!(disc.contains(&"wss://indexer.coracle.social".to_string()));
|
||||
// relay.floonet.dev and relay.0xchat.com carry the discovery role too.
|
||||
assert_eq!(disc.len(), 2);
|
||||
assert!(disc.contains(&"wss://relay.floonet.dev".to_string()));
|
||||
assert!(disc.contains(&"wss://relay.0xchat.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_field_is_optional_and_looked_up_by_url() {
|
||||
// The pinned pool advertises the money-path relay's co-located scoped
|
||||
// exit (the .AF floonet-mixexit) so it bootstraps OFFLINE, before any
|
||||
// network; every other relay is exit-less (reached over the tunnel).
|
||||
// The pinned pool no longer carries any co-located Nym exit — every relay
|
||||
// is reached over the Tor exit now — so has_exit() is false for it. The
|
||||
// exit_for / exit_for_host LOOKUP logic below still works for a pool that
|
||||
// DOES advertise one, and the (dormant) src/nym transport still reads it.
|
||||
let pinned = RelayPool::parse(PINNED_POOL).unwrap();
|
||||
assert!(pinned.has_exit());
|
||||
assert!(pinned.exit_for("wss://relay.floonet.dev").is_some());
|
||||
assert!(pinned.exit_for("wss://nos.lol").is_none());
|
||||
assert!(!pinned.has_exit());
|
||||
assert!(pinned.exit_for("wss://relay.floonet.dev").is_none());
|
||||
|
||||
// A pool that DOES advertise an exit for one relay.
|
||||
let pool = RelayPool::parse(
|
||||
@@ -545,7 +485,6 @@ mod tests {
|
||||
roles: vec!["dm".to_string()],
|
||||
vetted: vetted.then(|| "2026-07-01".to_string()),
|
||||
exit: None,
|
||||
onion: None,
|
||||
};
|
||||
vec![
|
||||
mk("wss://a.example", false),
|
||||
@@ -571,7 +510,6 @@ mod tests {
|
||||
roles: vec!["dm".to_string()],
|
||||
vetted: Some("2026-07-01".to_string()),
|
||||
exit: None,
|
||||
onion: None,
|
||||
});
|
||||
let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0);
|
||||
assert_eq!(order.len(), 4);
|
||||
|
||||
+12
-3
@@ -14,11 +14,20 @@
|
||||
|
||||
//! Default relay set and relay list helpers.
|
||||
|
||||
/// Default DM relays: the Floonet relay plus large public relays for redundancy.
|
||||
/// Default DM relays: the Floonet relay (the pinned shared floor) plus
|
||||
/// Tor-reachable public relays for redundancy.
|
||||
///
|
||||
/// TRANSPORT CONSTRAINT: Goblin dials every relay over Tor, so the defaults MUST
|
||||
/// be relays that accept Tor-exit connections. `relay.damus.io` and `nos.lol`
|
||||
/// throttle/block Tor exits — a wallet left on the raw defaults (e.g. when pool
|
||||
/// selection hasn't run or found nothing) then had NO working fallback whenever
|
||||
/// the Floonet onion flapped, so its payments stopped flowing. `relay.0xchat.com`
|
||||
/// and `offchain.pub` are Tor-friendly (and are also probe-vetted pool `dm`
|
||||
/// candidates), giving a real fallback that survives an onion drop.
|
||||
pub const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.floonet.dev",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.0xchat.com",
|
||||
"wss://offchain.pub",
|
||||
];
|
||||
|
||||
/// Default NIP-05 identity server.
|
||||
|
||||
+5
-45
@@ -15,12 +15,11 @@
|
||||
//! Embedded-Tor transport. Everything Goblin sends over the network — nostr relay
|
||||
//! websockets and every HTTP request (NIP-05, price, relay pool, avatars) — rides
|
||||
//! Tor, embedded in-process (arti), copied from our sister wallet GRIM's proven,
|
||||
//! shipping engine. The wallet dials the relay's pinned `.onion` over Tor and
|
||||
//! speaks PLAIN websocket to it (the onion connection is already encrypted +
|
||||
//! authenticated end to end — the `.onion` address IS the relay's public key — so
|
||||
//! a TLS wrapper is redundant and the relay backend does not serve it). Relays
|
||||
//! WITHOUT a pinned onion (e.g. a recipient's arbitrary DM relay) are reached over
|
||||
//! a Tor exit to their clearnet host, with the usual TLS for `wss://`.
|
||||
//! shipping engine. Every relay is reached over a Tor exit to its clearnet host,
|
||||
//! with the usual hostname-validated TLS for `wss://`: the wallet's own IP is
|
||||
//! never exposed, while the relay stays a normal public endpoint. (Earlier builds
|
||||
//! could pin a per-relay `.onion` for a direct onion-circuit money path; that was
|
||||
//! dropped in build134 — onion services flapped — in favour of Tor-exit only.)
|
||||
//!
|
||||
//! This replaces the Nym-mixnet transport (`crate::nym`, left dormant): Tor is
|
||||
//! free, unmetered, has no token or grant to expire, and GRIM has already proven
|
||||
@@ -81,45 +80,6 @@ pub(crate) fn cache_path() -> String {
|
||||
base.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
// --- Onion resolution ---------------------------------------------------------
|
||||
|
||||
/// The pinned `.onion` `(host, port)` for a relay `url`, if one is configured.
|
||||
/// The `GOBLIN_TOR_ONION` env override (for ad-hoc testing) wins; otherwise the
|
||||
/// pool's per-relay `onion` field ([`crate::nostr::pool::RelayPool::onion_for`]).
|
||||
/// `None` → this relay has no onion and is reached over a Tor exit to its clearnet
|
||||
/// host instead (see [`transport`]).
|
||||
pub(crate) fn onion_for(url: &str) -> Option<(String, u16)> {
|
||||
if let Ok(env) = std::env::var("GOBLIN_TOR_ONION") {
|
||||
let env = env.trim();
|
||||
if !env.is_empty() {
|
||||
return parse_onion(env);
|
||||
}
|
||||
}
|
||||
crate::nostr::pool::load()
|
||||
.onion_for(url)
|
||||
.and_then(|o| parse_onion(&o))
|
||||
}
|
||||
|
||||
/// Parse an onion target `host[:port]` → `(host, port)`. Defaults to port 80
|
||||
/// (plain ws over the onion). Tolerant of a `ws://`/`http://` prefix and a
|
||||
/// trailing slash so the pinned string may be written either way.
|
||||
fn parse_onion(s: &str) -> Option<(String, u16)> {
|
||||
let s = s
|
||||
.trim()
|
||||
.trim_start_matches("ws://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_end_matches('/');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match s.rsplit_once(':') {
|
||||
Some((host, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => {
|
||||
Some((host.to_string(), port.parse().ok()?))
|
||||
}
|
||||
_ => Some((s.to_string(), 80)),
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP over Tor ------------------------------------------------------------
|
||||
|
||||
/// An HTTP request routed over Tor: dial the host over Tor (an onion via a real
|
||||
|
||||
+15
-47
@@ -12,19 +12,14 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! WebSocket transport for the Nostr relay pool routed over embedded Tor.
|
||||
//! ANCHOR: a relay whose pool entry pins an `.onion`
|
||||
//! ([`crate::nostr::pool::PoolRelay::onion`]) is dialed straight to that onion
|
||||
//! over Tor — a real onion circuit, no exit node — and spoken to in PLAIN
|
||||
//! websocket ([`tokio_tungstenite::client_async`]). The onion connection is
|
||||
//! already encrypted AND authenticated end to end (the `.onion` address IS the
|
||||
//! relay's public key), so a TLS wrapper is redundant and the relay backend does
|
||||
//! not serve it. EXIT PATH (every relay without a pinned onion — e.g. a
|
||||
//! recipient's arbitrary DM relay a send fans out to): dial the relay's clearnet
|
||||
//! host over a Tor exit and run the usual hostname-validated TLS + websocket
|
||||
//! ([`tokio_tungstenite::client_async_tls`]) for `wss://`. Either way the payload
|
||||
//! and in-flight destination never touch the clear, and the wallet's own IP is
|
||||
//! never exposed.
|
||||
//! WebSocket transport for the Nostr relay pool routed over embedded Tor. Every
|
||||
//! relay is dialed the same way: reach its clearnet host over a Tor exit and run
|
||||
//! the usual hostname-validated TLS + websocket
|
||||
//! ([`tokio_tungstenite::client_async_tls`]) for `wss://`. The payload and the
|
||||
//! in-flight destination never touch the clear, and the wallet's own IP is never
|
||||
//! exposed. (Earlier builds could dial a relay's pinned `.onion` directly over a
|
||||
//! real onion circuit as a money-path anchor; that was dropped in build134 — the
|
||||
//! onion services flapped — leaving the Tor-exit path as the only one.)
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
@@ -70,37 +65,11 @@ impl WebSocketTransport for TorWebSocketTransport {
|
||||
return Err(terr("tor client not bootstrapped"));
|
||||
}
|
||||
|
||||
// MONEY-PATH ANCHOR: when the pool pins this relay's `.onion`, dial it
|
||||
// directly over Tor and speak PLAIN websocket — the onion connection is
|
||||
// already encrypted+authenticated end to end (the `.onion` IS the
|
||||
// relay's public key), so no TLS on top.
|
||||
if let Some((onion, port)) = crate::tor::onion_for(url.as_str()) {
|
||||
let t = Instant::now();
|
||||
let stream = tokio::time::timeout(timeout, crate::tor::connect(&onion, port))
|
||||
.await
|
||||
.map_err(|_| terr("tor onion connect timeout"))?
|
||||
.map_err(terr)?;
|
||||
// PLAIN ws over the onion (client_async, NOT client_async_tls). The
|
||||
// handshake targets the onion host itself.
|
||||
let ws_url = format!("ws://{onion}/");
|
||||
let (ws, _response) = tokio::time::timeout(
|
||||
timeout,
|
||||
tokio_tungstenite::client_async(ws_url.as_str(), stream),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| terr("websocket handshake timeout (onion)"))?
|
||||
.map_err(|e| terr(format!("websocket handshake failed (onion): {e}")))?;
|
||||
log::info!(
|
||||
"[timing] tor: relay {host} CONNECTED via onion — stream+ws {}ms",
|
||||
t.elapsed().as_millis()
|
||||
);
|
||||
return Ok(split_ws(ws));
|
||||
}
|
||||
|
||||
// EXIT PATH: no pinned onion → reach the relay's clearnet host over a
|
||||
// Tor exit, with the usual TLS + websocket for wss (SNI = the relay
|
||||
// host). This is what lets a send fan out to a recipient's arbitrary
|
||||
// public DM relays over Tor.
|
||||
// Reach the relay's clearnet host over a Tor exit, with the usual
|
||||
// hostname-validated TLS + websocket for wss (SNI = the relay host).
|
||||
// This is the single dial path: the wallet's IP never leaves Tor, and a
|
||||
// send fans out to a recipient's arbitrary public DM relays exactly the
|
||||
// way it reaches the shared floor relay (relay.floonet.dev).
|
||||
let port = url.port().unwrap_or(match url.scheme() {
|
||||
"ws" => 80,
|
||||
_ => 443,
|
||||
@@ -126,9 +95,8 @@ impl WebSocketTransport for TorWebSocketTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a websocket into the pool's boxed sink/stream halves — shared by the
|
||||
/// onion and exit 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, so everything above
|
||||
/// the byte transport is identical regardless of which Tor circuit carried it.
|
||||
fn split_ws<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
|
||||
|
||||
Reference in New Issue
Block a user