1
0
forked from GRIN/grim

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<bool> 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.
This commit is contained in:
2ro
2026-07-09 19:58:37 -04:00
parent d7ec692d03
commit 3c3b680f93
7 changed files with 376 additions and 17 deletions
Generated
+1
View File
@@ -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",
+4
View File
@@ -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"
+102
View File
@@ -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<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
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("<no-host>")
)
})
.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("<no-host>")
);
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<Vec<u8>>,
headers: &[(String, String)],
) -> Option<(u16, Vec<u8>, Option<String>)> {
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("<no-host>")
)
})
.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))
}
+72 -15
View File
@@ -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<std::collections::HashSet<String>>,
}
/// 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<NostrService>, 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.
+65
View File
@@ -54,6 +54,14 @@ pub struct NostrConfig {
/// advertised in our kind-0 profile so requesters see it before sending.
allow_incoming_requests: Option<bool>,
/// 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<bool>,
/// Path of the config file, not serialized.
#[serde(skip)]
path: Option<PathBuf>,
@@ -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());
}
}
+33 -2
View File
@@ -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 (`<base>/tor`).
@@ -103,6 +129,11 @@ pub async fn http_request_bytes(
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
// 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;
+99
View File
@@ -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<S>(
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<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)