Files
grim/src/tor/mod.rs
T
2ro 1e55ef5dfb 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.
2026-07-04 05:33:10 -04:00

262 lines
8.7 KiB
Rust

// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! 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. 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
//! the whole embedded path on desktop and Android.
//!
//! The Grin blockchain node is NOT routed here — it stays on the clear internet
//! exactly as before; it never sees who pays whom.
mod engine;
mod transport;
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;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper_util::rt::TokioIo;
use log::{debug, warn};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::Settings;
/// How long a single HTTP exchange (one redirect hop) may take end to end.
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
/// How long to wait for the embedded Tor client to bootstrap before giving up on
/// a request. A cold Tor bootstrap can take tens of seconds; a warm one is fast.
const TUNNEL_WAIT: Duration = Duration::from_secs(60);
/// Redirect hops to follow before giving up.
const MAX_REDIRECTS: usize = 5;
// --- Tor data directories -----------------------------------------------------
/// Base Tor data directory (`<base>/tor`).
fn base_path() -> PathBuf {
Settings::base_path(Some("tor".to_string()))
}
/// Tor state directory (consensus, guards, …). Used by [`engine`].
pub(crate) fn state_path() -> String {
let mut base = base_path();
base.push("state");
base.to_str().unwrap().to_string()
}
/// Tor cache directory (directory documents). Used by [`engine`].
pub(crate) fn cache_path() -> String {
let mut base = base_path();
base.push("cache");
base.to_str().unwrap().to_string()
}
// --- HTTP over Tor ------------------------------------------------------------
/// An HTTP request routed over Tor: dial the host over Tor (an onion via a real
/// onion circuit, a clearnet host via a Tor exit — arti resolves the name
/// internally, so nothing leaks a clearnet DNS lookup), then rustls (webpki
/// roots) for https, then HTTP/1.1. Follows redirects. Returns `(status, body)`.
///
/// For now clearnet-over-Tor is fine for the small lookups (names at goblin.st,
/// relay hints, pool refresh, price, avatars); pinning those behind onions is a
/// later pass.
pub async fn http_request_bytes(
method: &str,
url: String,
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
if !wait_ready(TUNNEL_WAIT).await {
warn!("tor http: client not bootstrapped, dropping request");
return None;
}
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,
request_once(&method, &url, body.clone(), &headers),
)
.await
.map_err(|_| warn!("tor http: request to {} timed out", redacted(&url)))
.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;
}
debug!(
"tor http: following {status} redirect to {}",
redacted(&url)
);
}
None => return Some((status, resp_body)),
}
}
warn!("tor http: too many redirects for {}", redacted(&url));
None
}
/// String-bodied convenience wrapper around [`http_request_bytes`].
pub async fn http_request(
method: &str,
url: String,
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
http_request_bytes(method, url, body.map(|b| b.into_bytes()), headers)
.await
.map(|(_, raw)| String::from_utf8_lossy(&raw).to_string())
}
/// Host without path/query, for logs (never log full URLs).
fn redacted(url: &url::Url) -> String {
url.host_str().unwrap_or("<no-host>").to_string()
}
/// A single HTTP/1.1 exchange over Tor. Returns the status, the collected body
/// and, for 3xx responses, the `Location` target.
async fn request_once(
method: &str,
url: &url::Url,
body: Option<Vec<u8>>,
headers: &[(String, String)],
) -> Option<(u16, Vec<u8>, Option<String>)> {
let host = url.host_str()?.to_string();
let https = url.scheme() == "https";
let port = url.port().unwrap_or(if https { 443 } else { 80 });
let tcp = connect(&host, port)
.await
.map_err(|e| warn!("tor http: connect to {host} failed: {e}"))
.ok()?;
let io: Box<dyn Stream> = if https {
Box::new(tls_connect(&host, tcp).await?)
} else {
Box::new(tcp)
};
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
.await
.map_err(|e| warn!("tor http: handshake with {host} failed: {e}"))
.ok()?;
// Drive the connection in the background for this one exchange.
tokio::spawn(async move {
let _ = conn.await;
});
let m = hyper::Method::from_bytes(method.as_bytes()).ok()?;
let path = match url.query() {
Some(q) => format!("{}?{q}", url.path()),
None => url.path().to_string(),
};
let host_header = if (https && port == 443) || (!https && port == 80) {
host.clone()
} else {
format!("{host}:{port}")
};
let mut req = hyper::Request::builder()
.method(m)
.uri(path)
.header(hyper::header::HOST, host_header)
.header(hyper::header::USER_AGENT, "goblin-wallet");
for (k, v) in headers {
req = req.header(k, v);
}
let req = req
.body(Full::new(Bytes::from(body.unwrap_or_default())))
.ok()?;
let resp = sender
.send_request(req)
.await
.map_err(|e| warn!("tor http: request to {host} failed: {e}"))
.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))
}
/// Everything hyper (and the TLS layer) needs from a Tor-carried stream, boxable
/// for the plain-http / https split.
pub(crate) trait Stream: AsyncRead + AsyncWrite + Send + Unpin {}
impl<T: AsyncRead + AsyncWrite + Send + Unpin> Stream for T {}
lazy_static::lazy_static! {
/// Shared rustls client config (webpki roots; ring provider installed at
/// startup — see lib.rs), reused by every clearnet-over-Tor https handshake.
/// Never the platform verifier — it panics on Android outside a full app
/// context.
static ref TLS_CONFIG: Arc<rustls::ClientConfig> = {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
)
};
}
/// The shared rustls client config (cheap `Arc` bump).
pub(crate) fn tls_config() -> Arc<rustls::ClientConfig> {
TLS_CONFIG.clone()
}
/// TLS-wrap a Tor-carried TCP stream with rustls + webpki roots. The certificate
/// is validated against the HOSTNAME, so a hostile Tor exit cannot MITM a
/// clearnet https fetch.
async fn tls_connect<S>(host: &str, stream: S) -> Option<tokio_rustls::client::TlsStream<S>>
where
S: AsyncRead + AsyncWrite + Send + Unpin,
{
let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).ok()?;
tokio_rustls::TlsConnector::from(tls_config())
.connect(server_name, stream)
.await
.map_err(|e| warn!("tor http: tls handshake with {host} failed: {e}"))
.ok()
}