From 3c3b680f93f32fc9f494e41ddb0fe10ef65fb310 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:58:37 -0400 Subject: [PATCH] tor: per-wallet Tor setting + reintroduce the clearnet transport/HTTP path Foundation slice for making Tor a wallet-level user setting (Phase 2, slice 1 of the per-user-tor plan). Transport/config only; no onboarding/settings UI, relay-set swap, or nprofile in this slice. Wallet-level tor_enabled (tri-state): - NostrConfig gains tor_enabled: Option with the existing Option + serde(default) pattern. tor_enabled() resolves None -> ON, so every legacy nostr.toml keeps Tor with no migration; new wallets write an explicit value at onboarding (later slice). Adds tor_enabled_is_set() + set_tor_enabled() and a back-compat/tri-state unit test. Clearnet path (the big lift, rebuilt after the Nym removal took it out): - ClearnetWebSocketTransport parallel to TorWebSocketTransport: direct TLS websocket, honoring the user's AppConfig SOCKS5 proxy (tokio-socks, already in the tree) so a VPN/proxy user can front their traffic. Shares ws_config/ split_ws with the Tor transport. - Transport selection moved into run_service: the wallet's resolved tor_enabled() picks Tor vs clearnet when the one pool is built; the Tor-bootstrap wait is skipped for clearnet wallets. restart() re-enters here, so a settings toggle rebuilds the pool on the new transport. - Process-global route flag (tor::set_route_over_tor/route_over_tor), mirrored from the open wallet like set_home_domain, so free-function HTTP callers pick the matching transport off-thread. - tor::http_request_bytes takes a clearnet branch when the wallet is Tor-off (no more hard-fail on "Tor not bootstrapped"); new http::clearnet_request_bytes reuses the existing HttpClient (proxy-aware) with the same redirect behavior and browser-like UA. NIP-05, price, relay-pool gist and NIP-11 probes all ride it unchanged. Status/readiness: - New TransportStatus enum + NostrService::transport_status()/tor_routing(): exposes a "Connected (direct)" clearnet state so a Tor-off wallet does not read as "connecting over Tor" forever. The three UI status lines are rewired to it in a later slice; the state is exposed cleanly now. Grin node path unchanged (stays clearnet always). cargo check --all-targets green; targeted config/nostr/tor tests pass. --- Cargo.lock | 1 + Cargo.toml | 4 ++ src/http/client.rs | 102 +++++++++++++++++++++++++++++++++++++++++++ src/nostr/client.rs | 87 +++++++++++++++++++++++++++++------- src/nostr/config.rs | 65 +++++++++++++++++++++++++++ src/tor/mod.rs | 35 ++++++++++++++- src/tor/transport.rs | 99 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 376 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d269507d..7b77cee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4215,6 +4215,7 @@ dependencies = [ "tokio 0.2.25", "tokio 1.49.0", "tokio-rustls 0.26.4", + "tokio-socks 0.5.3", "tokio-tungstenite", "tokio-util 0.2.0", "toml 0.9.11+spec-1.1.0", diff --git a/Cargo.toml b/Cargo.toml index 82d60582..e226e625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,10 @@ bytes = "1.11.0" hyper-socks2 = "0.9.1" hyper-proxy2 = "0.1.0" hyper-tls = "0.6.0" +# Raw SOCKS5 stream for the clearnet relay websocket, so a Tor-off wallet can +# still be fronted by the user's own SOCKS5 proxy/VPN (AppConfig.socks_proxy_url). +# Version already present transitively (async-socks5 tree) — no new download. +tokio-socks = "0.5" async-std = "1.13.2" uuid = { version = "0.8.2", features = ["v4"] } num-bigint = "0.4.6" diff --git a/src/http/client.rs b/src/http/client.rs index 7ea01096..3e694f69 100644 --- a/src/http/client.rs +++ b/src/http/client.rs @@ -12,16 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; use hyper::body::{Body, Incoming}; use hyper::{Request, Response}; use hyper_proxy2::{Intercept, Proxy, ProxyConnector}; use hyper_tls::HttpsConnector; use hyper_util::client::legacy::{Client, Error}; use hyper_util::rt::TokioExecutor; +use log::warn; use serde::de::StdError; use crate::AppConfig; +/// How long a single clearnet HTTP exchange (one redirect hop) may take. +/// Matches the Tor path's ceiling so both transports behave the same to callers. +const HTTP_TIMEOUT: Duration = Duration::from_secs(60); + +/// Redirect hops to follow before giving up (mirrors the Tor path). +const MAX_REDIRECTS: usize = 5; + /// Handles http requests. pub struct HttpClient {} @@ -89,3 +101,93 @@ impl HttpClient { client.request(req).await } } + +/// A clearnet HTTP request, the direct counterpart to `crate::tor::http_request_bytes` +/// for Tor-off wallets. Same shape and redirect behavior, so every existing +/// caller (NIP-05, price, relay pool, NIP-11 probe) works unchanged once the +/// transport branch in `tor::http_request_bytes` routes here. Honors the user's +/// AppConfig proxy transparently via [`HttpClient::send`]. Returns `(status, body)`. +pub async fn clearnet_request_bytes( + method: &str, + url: String, + body: Option>, + headers: Vec<(String, String)>, +) -> Option<(u16, Vec)> { + let mut url = url::Url::parse(&url).ok()?; + let mut method = method.to_uppercase(); + let mut body = body; + for _ in 0..=MAX_REDIRECTS { + let (status, resp_body, location) = tokio::time::timeout( + HTTP_TIMEOUT, + clearnet_once(&method, &url, body.clone(), &headers), + ) + .await + .map_err(|_| { + warn!( + "clearnet http: request to {} timed out", + url.host_str().unwrap_or("") + ) + }) + .ok()??; + match location { + Some(loc) => { + url = url.join(&loc).ok()?; + // 303 (and legacy 301/302) turn into a bodiless GET; 307/308 replay. + if matches!(status, 301..=303) { + method = "GET".to_string(); + body = None; + } + } + None => return Some((status, resp_body)), + } + } + warn!( + "clearnet http: too many redirects for {}", + url.host_str().unwrap_or("") + ); + None +} + +/// A single clearnet HTTP/1.1 exchange (optionally proxied per AppConfig). +/// Returns the status, the collected body and, for 3xx responses, the +/// `Location` target — mirroring the Tor path's `request_once`. +async fn clearnet_once( + method: &str, + url: &url::Url, + body: Option>, + headers: &[(String, String)], +) -> Option<(u16, Vec, Option)> { + let m = hyper::Method::from_bytes(method.as_bytes()).ok()?; + let mut req = Request::builder() + .method(m) + .uri(url.as_str()) + // Same browser-like default UA as the Tor path, so the wallet's clearnet + // requests are not trivially classifiable as Goblin at the destination. + .header(hyper::header::USER_AGENT, crate::tor::DEFAULT_USER_AGENT); + for (k, v) in headers { + req = req.header(k.as_str(), v.as_str()); + } + let req = req + .body(Full::new(Bytes::from(body.unwrap_or_default()))) + .ok()?; + let resp = HttpClient::send(req) + .await + .map_err(|e| { + warn!( + "clearnet http: request to {} failed: {e}", + url.host_str().unwrap_or("") + ) + }) + .ok()?; + let status = resp.status().as_u16(); + let location = if resp.status().is_redirection() { + resp.headers() + .get(hyper::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + } else { + None + }; + let bytes = resp.into_body().collect().await.ok()?.to_bytes().to_vec(); + Some((status, bytes, location)) +} diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 6ff16729..1172f6fb 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -36,7 +36,7 @@ use crate::nostr::relays::MAX_DM_RELAYS; use crate::nostr::types::*; use crate::nostr::wrapv3; use crate::nostr::{NostrConfig, NostrIdentity, NostrStore}; -use crate::tor::TorWebSocketTransport; +use crate::tor::{ClearnetWebSocketTransport, TorWebSocketTransport}; use crate::wallet::Wallet; use crate::wallet::types::WalletTask; @@ -187,6 +187,23 @@ pub struct NostrService { announced_ok: RwLock>, } +/// Transport-aware connection state for the UI status lines. Tor and clearnet +/// are distinct so a Tor-off wallet never reads as "connecting over Tor". See +/// [`NostrService::transport_status`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TransportStatus { + /// Tor wallet: embedded Tor still bootstrapping / dialing. + ConnectingTor, + /// Tor wallet: tunnel up, but no relay live on the current generation yet. + TorReady, + /// Tor wallet: a relay is connected+subscribed over Tor. + ConnectedTor, + /// Clearnet wallet: dialing relays directly, none connected yet. + ConnectingDirect, + /// Clearnet wallet: a relay is connected directly ("Connected (direct)"). + ConnectedDirect, +} + /// Phase of the most recent outgoing send, polled by the send UI. pub mod send_phase { pub const IDLE: u8 = 0; @@ -600,6 +617,32 @@ impl NostrService { self.connected.load(Ordering::Relaxed) } + /// Whether this wallet routes over Tor (resolved; `None`/legacy = ON). + pub fn tor_routing(&self) -> bool { + self.config.read().tor_enabled() + } + + /// Transport-aware connection status for the UI status lines, so a Tor-off + /// wallet reads "Connected (direct)" instead of forever "connecting over Tor" + /// (the Tor-only [`crate::tor::transport_ready`] never flips on clearnet). + /// The three status-line call sites are rewired to this in a later slice; the + /// state is exposed cleanly here now. + pub fn transport_status(&self) -> TransportStatus { + if self.tor_routing() { + if crate::tor::transport_ready() { + TransportStatus::ConnectedTor + } else if crate::tor::is_ready() { + TransportStatus::TorReady + } else { + TransportStatus::ConnectingTor + } + } else if self.connected.load(Ordering::Relaxed) { + TransportStatus::ConnectedDirect + } else { + TransportStatus::ConnectingDirect + } + } + /// Whether the service loop is running. pub fn is_running(&self) -> bool { self.started.load(Ordering::Relaxed) && !self.shutdown.load(Ordering::Relaxed) @@ -1251,24 +1294,38 @@ async fn run_service(svc: Arc, wallet: Wallet) { // Mirror the configured name authority so resolution + display follow it. crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain()); - let client = Client::builder() - .signer(svc.keys.read().clone()) - .websocket_transport(TorWebSocketTransport) - .build(); - // Wait for the embedded Tor client before any network work (relay dials, pool - // refresh, NIP-11 probes). `warm_up()` starts it at launch, but a fast - // wallet-open can beat the cold Tor bootstrap — and dialing before it's up + // Resolve THIS wallet's transport choice and mirror it into the process-global + // so free-function HTTP callers (NIP-05, price, pool) take the matching path. + // `None` (every legacy nostr.toml) resolves to Tor ON — upgraders keep Tor. + let over_tor = svc.config.read().tor_enabled(); + crate::tor::set_route_over_tor(over_tor); + + // One relay pool serves the whole wallet; its transport is fixed when the + // Client is built. Pick Tor vs clearnet from the wallet setting. A settings + // toggle calls restart(), which re-enters here and rebuilds on the new choice. + let mut builder = Client::builder().signer(svc.keys.read().clone()); + builder = if over_tor { + builder.websocket_transport(TorWebSocketTransport) + } else { + builder.websocket_transport(ClearnetWebSocketTransport) + }; + let client = builder.build(); + // Tor wallets: wait for the embedded Tor client before any network work (relay + // dials, pool refresh, NIP-11 probes). `warm_up()` starts it at launch, but a + // fast wallet-open can beat the cold Tor bootstrap — and dialing before it's up // drops every relay into nostr-sdk's backing-off reconnect, leaving the wallet // on "Connecting…" long after Tor is actually ready. Once it's bootstrapped - // this returns immediately. - for i in 0..240u32 { - if crate::tor::is_ready() { - if i > 0 { - info!("nostr: Tor ready after ~{}ms, dialing relays", i * 500); + // this returns immediately. Clearnet wallets skip the wait entirely. + if over_tor { + for i in 0..240u32 { + if crate::tor::is_ready() { + if i > 0 { + info!("nostr: Tor ready after ~{}ms, dialing relays", i * 500); + } + break; } - break; + tokio::time::sleep(Duration::from_millis(500)).await; } - tokio::time::sleep(Duration::from_millis(500)).await; } // We are now a relay consumer (API parity with the old transport; inert under // Tor, which manages its own circuit health). Disarmed when the loop exits. diff --git a/src/nostr/config.rs b/src/nostr/config.rs index 4f818780..5717079e 100644 --- a/src/nostr/config.rs +++ b/src/nostr/config.rs @@ -54,6 +54,14 @@ pub struct NostrConfig { /// advertised in our kind-0 profile so requesters see it before sending. allow_incoming_requests: Option, + /// Whether this wallet routes its nostr relay traffic and every sensitive + /// HTTP call (NIP-05, price, relay pool) over Tor. Tri-state on purpose: + /// `Some(true)` = Tor on, `Some(false)` = clearnet, `None` (unset) resolves + /// to ON. `None` is what every pre-existing `nostr.toml` deserializes to, so + /// upgrading wallets keep Tor with no migration; new wallets write an explicit + /// value at onboarding (a later slice). + tor_enabled: Option, + /// Path of the config file, not serialized. #[serde(skip)] path: Option, @@ -163,4 +171,61 @@ impl NostrConfig { self.allow_incoming_requests = Some(allow); self.save(); } + + /// Resolved Tor routing for this wallet: `None` (unset, i.e. every legacy + /// `nostr.toml`) resolves to ON so upgraders keep Tor with no migration. + /// New wallets write an explicit value at onboarding (a later slice). + pub fn tor_enabled(&self) -> bool { + self.tor_enabled.unwrap_or(true) + } + + /// Whether the wallet has an EXPLICIT Tor choice on disk (vs the `None` + /// upgrade default). Onboarding uses this to know it must still write one. + pub fn tor_enabled_is_set(&self) -> bool { + self.tor_enabled.is_some() + } + + /// Persist an explicit Tor routing choice. Toggling this at runtime must be + /// followed by `NostrService::restart()` so the relay pool is rebuilt on the + /// newly-selected transport (see `src/nostr/client.rs::run_service`). + pub fn set_tor_enabled(&mut self, enabled: bool) { + self.tor_enabled = Some(enabled); + self.save(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tor_enabled_tri_state_resolves_and_is_back_compat() { + // A brand-new/default config has no explicit choice and resolves to ON, + // so nothing about the default flips a wallet off Tor. + let cfg = NostrConfig::default(); + assert!(!cfg.tor_enabled_is_set()); + assert!(cfg.tor_enabled(), "unset must resolve to Tor ON"); + + // The load-bearing upgrade guarantee: an OLD nostr.toml written before + // this field existed deserializes with tor_enabled = None -> ON. Include + // an unrelated field so we exercise a realistic legacy file. + let legacy: NostrConfig = + toml::from_str("accept_from = \"Everyone\"\n").expect("legacy nostr.toml parses"); + assert!(!legacy.tor_enabled_is_set()); + assert!(legacy.tor_enabled(), "legacy file must keep Tor ON"); + + // Explicit values round-trip both ways (save() is a no-op with no path). + let mut cfg = NostrConfig::default(); + cfg.set_tor_enabled(false); + assert!(cfg.tor_enabled_is_set()); + assert!(!cfg.tor_enabled()); + cfg.set_tor_enabled(true); + assert!(cfg.tor_enabled()); + + // An explicit false on disk parses back as clearnet. + let off: NostrConfig = + toml::from_str("tor_enabled = false\n").expect("explicit-off nostr.toml parses"); + assert!(off.tor_enabled_is_set()); + assert!(!off.tor_enabled()); + } } diff --git a/src/tor/mod.rs b/src/tor/mod.rs index c67dc6bd..1f11a69e 100644 --- a/src/tor/mod.rs +++ b/src/tor/mod.rs @@ -34,10 +34,11 @@ pub use engine::{ Client, client, condemn_exit, connect, is_ready, report_relay_down, report_relay_live, set_relay_consumer, transport_ready, tunnel_generation, wait_ready, warm_up, }; -pub use transport::TorWebSocketTransport; +pub use transport::{ClearnetWebSocketTransport, TorWebSocketTransport}; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use bytes::Bytes; @@ -63,9 +64,34 @@ const MAX_REDIRECTS: usize = 5; /// classifiable as Goblin at the destination. Callers may still override it by /// passing their own `User-Agent` in the headers list; the endpoints Goblin talks /// to (GitHub API, CoinGecko, name authorities) all accept a generic UA. -const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \ +pub(crate) const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \ Chrome/131.0.0.0 Safari/537.36"; +/// Process-global transport choice for the OPEN wallet: `true` = route nostr +/// relays and every sensitive HTTP call over Tor (the historical default), +/// `false` = go direct (clearnet, optionally via the user's AppConfig proxy). +/// +/// Tor is process-global and one relay pool serves the whole wallet, so this +/// single flag is the honest home for the choice. `run_service` mirrors the +/// open wallet's resolved `NostrConfig::tor_enabled()` into it before any +/// network work (like `nip05::set_home_domain`), so free-function HTTP callers +/// on worker threads read the right transport without threading a wallet handle +/// through every call site. A Tor-off wallet never hard-fails on "Tor not +/// bootstrapped" — it simply takes the clearnet branch. +static ROUTE_OVER_TOR: AtomicBool = AtomicBool::new(true); + +/// Mirror the open wallet's resolved Tor routing choice. Called by +/// `run_service` on start/restart; a settings toggle takes effect after the +/// `NostrService::restart()` that rebuilds the pool on the new transport. +pub fn set_route_over_tor(enabled: bool) { + ROUTE_OVER_TOR.store(enabled, Ordering::Release); +} + +/// Whether the open wallet currently routes over Tor (see [`set_route_over_tor`]). +pub fn route_over_tor() -> bool { + ROUTE_OVER_TOR.load(Ordering::Acquire) +} + // --- Tor data directories ----------------------------------------------------- /// Base Tor data directory (`/tor`). @@ -103,6 +129,11 @@ pub async fn http_request_bytes( body: Option>, headers: Vec<(String, String)>, ) -> Option<(u16, Vec)> { + // Tor-off wallet: go direct (clearnet, honoring the user's AppConfig proxy). + // Never wait on / hard-fail against the Tor bootstrap in this mode. + if !route_over_tor() { + return crate::http::clearnet_request_bytes(method, url, body, headers).await; + } if !wait_ready(TUNNEL_WAIT).await { warn!("tor http: client not bootstrapped, dropping request"); return None; diff --git a/src/tor/transport.rs b/src/tor/transport.rs index 6580ed9d..8c92e4b8 100644 --- a/src/tor/transport.rs +++ b/src/tor/transport.rs @@ -117,6 +117,105 @@ impl WebSocketTransport for TorWebSocketTransport { } } +/// Nostr websocket transport over the clear internet, the direct counterpart to +/// [`TorWebSocketTransport`] for a Tor-off wallet. Dials the relay host directly +/// (or through the user's own SOCKS5 proxy, if one is configured in +/// [`crate::AppConfig`], so a VPN/proxy user can still front their traffic), then +/// runs the usual hostname-validated TLS + websocket. The wallet's IP IS visible +/// to the relay in this mode — that is the deliberate speed/onboarding tradeoff of +/// turning Tor off, and the onboarding copy recommends at least a VPN. +#[derive(Debug, Clone, Copy, Default)] +pub struct ClearnetWebSocketTransport; + +impl WebSocketTransport for ClearnetWebSocketTransport { + fn support_ping(&self) -> bool { + true + } + + fn connect<'a>( + &'a self, + url: &'a Url, + _mode: &'a ConnectionMode, + timeout: Duration, + ) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> { + Box::pin(async move { + let host = url + .host_str() + .ok_or_else(|| terr("relay url has no host"))? + .to_string(); + let port = url.port().unwrap_or(match url.scheme() { + "ws" => 80, + _ => 443, + }); + let t = Instant::now(); + // Route through the user's SOCKS5 proxy when they've enabled one; + // otherwise dial the relay directly. Either way the same + // hostname-validated TLS + websocket runs on top (SNI = relay host). + let via_socks = crate::AppConfig::use_proxy() + && crate::AppConfig::use_socks_proxy() + && crate::AppConfig::socks_proxy_url().is_some(); + let result = if via_socks { + let proxy = crate::AppConfig::socks_proxy_url().unwrap(); + let proxy = proxy + .trim() + .trim_start_matches("socks5h://") + .trim_start_matches("socks5://") + .to_string(); + let stream = tokio::time::timeout( + timeout, + tokio_socks::tcp::Socks5Stream::connect(proxy.as_str(), (host.as_str(), port)), + ) + .await + .map_err(|_| terr("socks proxy connect timeout"))? + .map_err(|e| terr(format!("socks proxy connect failed: {e}")))?; + ws_handshake(url, stream, timeout).await + } else { + let stream = tokio::time::timeout( + timeout, + tokio::net::TcpStream::connect((host.as_str(), port)), + ) + .await + .map_err(|_| terr("tcp connect timeout"))? + .map_err(|e| terr(format!("tcp connect failed: {e}")))?; + ws_handshake(url, stream, timeout).await + }; + if result.is_ok() { + log::info!( + "[timing] clearnet: relay {host} CONNECTED — tls+ws {}ms", + t.elapsed().as_millis() + ); + } + result + }) + } +} + +/// Run the hostname-validated TLS + websocket handshake over an already-dialed +/// stream and split it into the pool's sink/stream halves. Shared by the direct +/// and SOCKS5 clearnet dial paths (the Tor path has its own inline handshake). +async fn ws_handshake( + url: &Url, + stream: S, + timeout: Duration, +) -> Result<(WebSocketSink, WebSocketStream), TransportError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, +{ + let (ws, _response) = tokio::time::timeout( + timeout, + tokio_tungstenite::client_async_tls_with_config( + url.as_str(), + stream, + Some(ws_config()), + None, + ), + ) + .await + .map_err(|_| terr("websocket handshake timeout"))? + .map_err(|e| terr(format!("websocket handshake failed: {e}")))?; + Ok(split_ws(ws)) +} + /// 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(ws: tokio_tungstenite::WebSocketStream) -> (WebSocketSink, WebSocketStream)