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:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user