From 45f4b7a00be3f4d92dfd0f2defcd7bfa521fb2bd Mon Sep 17 00:00:00 2001 From: fleebicorn <278484091+fleebicorn@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:30:03 +0000 Subject: [PATCH 1/3] add shared tor socks plumbing across the backend Add a shared tor-socks5 crate and move the existing SOCKS5 handling there. Use it from swap transport, HTTP, electrum, bitcoin wallet and monero callers so the proxy rules live in one place. --- Cargo.lock | 34 +++ Cargo.toml | 3 + bitcoin-wallet/Cargo.toml | 1 + bitcoin-wallet/src/wallet.rs | 13 +- electrum-pool/Cargo.toml | 1 + electrum-pool/src/lib.rs | 18 +- monero-harness/src/lib.rs | 1 + monero-rpc-pool/Cargo.toml | 1 + monero-rpc-pool/src/lib.rs | 2 + monero-rpc-pool/src/proxy.rs | 18 +- monero-sys/Cargo.toml | 1 + monero-sys/src/lib.rs | 69 +++++- swap-asb/src/main.rs | 18 +- swap/Cargo.toml | 2 + swap/src/cli/api.rs | 42 +++- swap/src/cli/api/request.rs | 28 ++- swap/src/cli/api/tauri_bindings.rs | 100 +++++++- swap/src/cli/command.rs | 8 +- swap/src/cli/transport.rs | 232 ++++++++++++++++-- swap/src/common/http.rs | 123 ++++++++++ swap/src/common/mod.rs | 10 +- swap/tests/harness/mod.rs | 1 + tor-socks5/Cargo.toml | 16 ++ tor-socks5/src/lib.rs | 364 +++++++++++++++++++++++++++++ 24 files changed, 1042 insertions(+), 64 deletions(-) create mode 100644 swap/src/common/http.rs create mode 100644 tor-socks5/Cargo.toml create mode 100644 tor-socks5/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1f6bb787c0..576db51181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1127,6 +1127,7 @@ dependencies = [ "swap-serde", "thiserror 1.0.69", "tokio", + "tor-socks5", "tracing", ] @@ -3057,6 +3058,7 @@ dependencies = [ "futures", "once_cell", "tokio", + "tor-socks5", "tracing", ] @@ -6523,6 +6525,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tor-rtcompat", + "tor-socks5", "tower-http", "tracing", "tracing-subscriber", @@ -6577,6 +6580,7 @@ dependencies = [ "thiserror 2.0.18", "throttle", "tokio", + "tor-socks5", "tracing", "tracing-subscriber", "typeshare", @@ -10652,11 +10656,13 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", + "tokio-socks", "tokio-util", "tor-hscrypto", "tor-hsservice", "tor-llcrypto", "tor-rtcompat", + "tor-socks5", "tracing", "tracing-appender", "tracing-ext", @@ -11809,6 +11815,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -13029,6 +13047,16 @@ dependencies = [ "web-time-compat", ] +[[package]] +name = "tor-socks5" +version = "0.1.0" +dependencies = [ + "reqwest 0.12.28", + "tokio", + "tokio-socks", + "tracing", +] + [[package]] name = "tor-socksproto" version = "0.41.0" @@ -13515,7 +13543,10 @@ dependencies = [ name = "unstoppableswap-gui-rs" version = "4.5.0" dependencies = [ + "anyhow", "dfx-swiss-sdk", + "reqwest 0.12.28", + "reqwest 0.13.2", "rustls 0.23.37", "serde", "serde_json", @@ -13533,7 +13564,10 @@ dependencies = [ "tauri-plugin-updater", "tokio", "tokio-util", + "toml 0.9.12+spec-1.1.0", + "tor-socks5", "tracing", + "url", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index d4574e625c..99c47bd777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "swap-proptest", "swap-serde", "throttle", + "tor-socks5", "tracing-ext", ] @@ -92,7 +93,9 @@ uuid = { version = "1", features = ["v4"] } # Tokio testcontainers = "0.15" tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync"] } +tokio-socks = "0.5.2" tokio-util = { version = "0.7", features = ["io", "codec", "rt"] } +tor-socks5 = { path = "./tor-socks5" } # Tor/Arti crates arti-client = { git = "https://github.com/eigenwallet/arti", branch = "downgraded_rusqlite_arti_2_2_0", default-features = false } diff --git a/bitcoin-wallet/Cargo.toml b/bitcoin-wallet/Cargo.toml index 430dc56e6b..c975653c4c 100644 --- a/bitcoin-wallet/Cargo.toml +++ b/bitcoin-wallet/Cargo.toml @@ -15,6 +15,7 @@ bdk_wallet = { workspace = true, features = ["rusqlite", "test-utils"] } bitcoin = { workspace = true } derive_builder = "0.20.2" electrum-pool = { path = "../electrum-pool" } +tor-socks5 = { workspace = true, features = ["reqwest"] } futures = { workspace = true } moka = { version = "0.12", features = ["sync", "future"] } proptest = "1" diff --git a/bitcoin-wallet/src/wallet.rs b/bitcoin-wallet/src/wallet.rs index 033b0cc794..06a76c5cb2 100644 --- a/bitcoin-wallet/src/wallet.rs +++ b/bitcoin-wallet/src/wallet.rs @@ -2614,8 +2614,17 @@ mod mempool_client { _ => bail!("mempool.space fee estimation unsupported for network"), }; - let client = reqwest::Client::builder() - .timeout(HTTP_TIMEOUT) + let mut builder = reqwest::Client::builder().timeout(HTTP_TIMEOUT); + + if let Some(proxy) = tor_socks5::proxy_config(tor_socks5::Subsystem::Bitcoin) { + builder = builder.proxy( + proxy + .reqwest_proxy() + .context("Failed to configure system Tor SOCKS5 for mempool.space")?, + ); + } + + let client = builder .build() .context("Failed to build mempool.space HTTP client")?; diff --git a/electrum-pool/Cargo.toml b/electrum-pool/Cargo.toml index 60b2473f6a..6b9e10173f 100644 --- a/electrum-pool/Cargo.toml +++ b/electrum-pool/Cargo.toml @@ -11,4 +11,5 @@ bitcoin = { workspace = true } futures = { workspace = true } once_cell = { workspace = true } tokio = { workspace = true } +tor-socks5 = { workspace = true } tracing = { workspace = true } diff --git a/electrum-pool/src/lib.rs b/electrum-pool/src/lib.rs index f5b46eb229..c291c03877 100644 --- a/electrum-pool/src/lib.rs +++ b/electrum-pool/src/lib.rs @@ -1,6 +1,6 @@ use backoff::{Error as BackoffError, ExponentialBackoff}; use bdk_electrum::BdkElectrumClient; -use bdk_electrum::electrum_client::{Client, ConfigBuilder, ElectrumApi, Error}; +use bdk_electrum::electrum_client::{Client, ConfigBuilder, ElectrumApi, Error, Socks5Config}; use bitcoin::Transaction; use futures::future::join_all; use once_cell::sync::OnceCell; @@ -569,7 +569,15 @@ impl ElectrumClientFactory> for BdkElectrumClientFacto url: &str, config: &ElectrumBalancerConfig, ) -> Result>, Error> { + let socks5 = tor_socks5::proxy_config(tor_socks5::Subsystem::Electrum).map(|proxy| { + Socks5Config::with_credentials( + proxy.addr.to_string(), + proxy.username.to_string(), + proxy.password.to_string(), + ) + }); let client_config = ConfigBuilder::new() + .socks5(socks5.clone()) .timeout(Some(config.request_timeout)) // TODO: Why is this set to 1? // The goal of this crate is to extract retry logic out of the electrum client library @@ -579,6 +587,14 @@ impl ElectrumClientFactory> for BdkElectrumClientFacto .retry(1) .build(); + if socks5.is_some() { + debug!( + server_url = url, + proxy = %tor_socks5::current_addr(), + "Using system Tor SOCKS5 proxy for Electrum connection" + ); + } + let client = Client::from_config(url, client_config).map_err(|e| { // Wrap connection errors with DNS resolution context match &e { diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs index 74963b9105..3aa12b2159 100644 --- a/monero-harness/src/lib.rs +++ b/monero-harness/src/lib.rs @@ -93,6 +93,7 @@ impl<'c> Monero { hostname: "127.0.0.1".to_string(), port: monerod_port, ssl: false, + proxy_address: None, } }; diff --git a/monero-rpc-pool/Cargo.toml b/monero-rpc-pool/Cargo.toml index 4179a0cbeb..6a954e5def 100644 --- a/monero-rpc-pool/Cargo.toml +++ b/monero-rpc-pool/Cargo.toml @@ -35,6 +35,7 @@ tracing-subscriber = { workspace = true } # Async runtime crossbeam = "0.8.4" tokio = { workspace = true, features = ["full"] } +tor-socks5 = { workspace = true } # Serialization chrono = { version = "0.4", features = ["serde"] } diff --git a/monero-rpc-pool/src/lib.rs b/monero-rpc-pool/src/lib.rs index 4958148433..957413b7cb 100644 --- a/monero-rpc-pool/src/lib.rs +++ b/monero-rpc-pool/src/lib.rs @@ -31,6 +31,7 @@ use proxy::{proxy_handler, stats_handler}; pub struct AppState { pub node_pool: Arc, pub tor_client: Option, + pub system_tor_socks5: Option, pub connection_pool: crate::connection_pool::ConnectionPool, } @@ -109,6 +110,7 @@ pub async fn create_app_with_receiver( let app_state = AppState { node_pool, tor_client: config.tor_client, + system_tor_socks5: tor_socks5::proxy_config(tor_socks5::Subsystem::MoneroRpc), connection_pool: crate::connection_pool::ConnectionPool::new(), }; diff --git a/monero-rpc-pool/src/proxy.rs b/monero-rpc-pool/src/proxy.rs index f3e2e7f2e9..c4c092f9f6 100644 --- a/monero-rpc-pool/src/proxy.rs +++ b/monero-rpc-pool/src/proxy.rs @@ -494,6 +494,16 @@ async fn proxy_to_single_node( .await .map_err(|e| SingleRequestError::ConnectionError(format!("{e:?}")))?; + Box::new(stream) + } else if let Some(proxy) = state + .system_tor_socks5 + .filter(|_| !request.clearnet_whitelisted()) + { + let stream = proxy + .connect(address) + .await + .map_err(|e| SingleRequestError::ConnectionError(format!("{e:?}")))?; + Box::new(stream) } else { let stream = TcpStream::connect(address) @@ -530,7 +540,13 @@ async fn proxy_to_single_node( tracing::trace!( "Established new connection via {}{}", - if use_tor { "Tor" } else { "clearnet" }, + if use_tor { + "Tor" + } else if state.system_tor_socks5.is_some() && !request.clearnet_whitelisted() { + "system Tor SOCKS5" + } else { + "clearnet" + }, if node.0 == "https" { " with TLS" } else { "" } ); } diff --git a/monero-sys/Cargo.toml b/monero-sys/Cargo.toml index dbfbcf6519..5efff5ff10 100644 --- a/monero-sys/Cargo.toml +++ b/monero-sys/Cargo.toml @@ -23,6 +23,7 @@ monero-address = { workspace = true } monero-oxide-ext = { path = "../monero-oxide-ext" } swap-serde = { path = "../swap-serde" } throttle = { path = "../throttle" } +tor-socks5 = { workspace = true } [build-dependencies] cmake = "0.1.54" diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index ee3153c552..0cc8f30a6b 100644 --- a/monero-sys/src/lib.rs +++ b/monero-sys/src/lib.rs @@ -38,6 +38,7 @@ use bridge::ffi::{self}; use typeshare::typeshare; use uuid::Uuid; + /// Approval callback for transactions /// The callback receives (txid, amount, fee) and returns whether to proceed with the transaction pub type ApprovalCallback = Arc< @@ -202,6 +203,7 @@ pub struct Daemon { pub hostname: String, pub port: u16, pub ssl: bool, + pub proxy_address: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1075,6 +1077,7 @@ impl WalletHandle { hostname: "localhost".to_string(), port: 18081, ssl: false, + proxy_address: None, }, &wallet_name, )?; @@ -1895,7 +1898,7 @@ impl FfiWallet { let_cxx_string!(daemon_address = daemon_address); let_cxx_string!(daemon_username = ""); let_cxx_string!(daemon_password = ""); - let_cxx_string!(proxy_address = ""); + let_cxx_string!(proxy_address = daemon.proxy_address.as_deref().unwrap_or("")); let raw_wallet = &mut self.inner; @@ -3116,11 +3119,21 @@ impl TryFrom for Daemon { .ok_or_else(|| anyhow::anyhow!("No port found in URL"))?; let ssl = url.scheme() == "https"; + // wallet2's C++ `init(..., proxy_address)` accepts only a host:port + // string — no SOCKS5 credentials — so we use `proxy_addr()` instead of + // `proxy_config(Subsystem::…)`. Consequence: Monero wallet traffic + // cannot be stream-isolated from other wallet connections on the Tor + // side (all share the same circuit). See `tor-socks5` module docs. + let proxy_address = (!tor_socks5::is_local_host(&hostname)) + .then(|| tor_socks5::proxy_addr()) + .flatten() + .map(|a| a.to_string()); Ok(Daemon { hostname, port, ssl, + proxy_address, }) } } @@ -3345,3 +3358,57 @@ fn backoff( .with_max_interval(max_interval) .build() } + +#[cfg(test)] +mod tests { + use super::*; + + /// `tor_socks5` is a process-global singleton, so the Daemon proxy tests + /// serialize on this lock to stop `cargo test` parallelism from letting + /// one test's enable leak into another's disable. + static PROXY_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn test_daemon_local_host_never_gets_proxy() { + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + + // Even with the SOCKS5 singleton enabled, a loopback daemon URL must + // stay direct — wallet2 talking to 127.0.0.1 through Tor would be + // a configuration smell and waste a circuit. + tor_socks5::enable_with_addr(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::new(127, 0, 0, 1), + 9050, + )); + let daemon = Daemon::try_from("http://127.0.0.1:18081".to_string()).unwrap(); + assert_eq!(daemon.proxy_address, None); + tor_socks5::disable(); + } + + #[test] + fn test_daemon_remote_host_without_proxy() { + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + + tor_socks5::disable(); + let daemon = Daemon::try_from("http://xmr.example.com:18081".to_string()).unwrap(); + assert_eq!(daemon.proxy_address, None); + assert_eq!(daemon.hostname, "xmr.example.com"); + assert_eq!(daemon.port, 18081); + assert!(!daemon.ssl); + } + + #[test] + fn test_daemon_remote_host_propagates_proxy_address() { + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + + let proxy = std::net::SocketAddrV4::new(std::net::Ipv4Addr::new(10, 152, 152, 10), 9050); + tor_socks5::enable_with_addr(proxy); + + let daemon = Daemon::try_from("https://xmr.example.com:18089".to_string()).unwrap(); + // wallet2 needs a bare host:port string — no creds, since its C++ + // `init(.., proxy_address)` doesn't accept a SOCKS5 auth pair. + assert_eq!(daemon.proxy_address, Some("10.152.152.10:9050".to_string())); + assert!(daemon.ssl); + + tor_socks5::disable(); + } +} diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index aff44f5308..9eefdadb1b 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -73,22 +73,7 @@ trait IntoDaemon { impl IntoDaemon for url::Url { fn into_daemon(self) -> Result { - let hostname = self - .host_str() - .ok_or_else(|| anyhow::anyhow!("No hostname found in URL"))? - .to_string(); - - let port = self - .port() - .ok_or_else(|| anyhow::anyhow!("No port found in URL"))?; - - let ssl = self.scheme() == "https"; - - Ok(Daemon { - hostname, - port, - ssl, - }) + self.to_string().try_into() } } @@ -98,6 +83,7 @@ impl IntoDaemon for monero_rpc_pool::ServerInfo { hostname: self.host, port: self.port, ssl: false, + proxy_address: None, }) } } diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 2148cf2376..2ad8f94171 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -23,6 +23,8 @@ monero-wallet = { path = "../monero-wallet" } # Tor arti-client = { workspace = true, features = ["static-sqlite", "tokio", "rustls", "onion-service-service", "hs-pow-full", "ephemeral-keystore"] } +tokio-socks = { workspace = true } +tor-socks5 = { workspace = true, features = ["reqwest"] } tor-hscrypto = { workspace = true } tor-hsservice = { workspace = true } tor-llcrypto = { workspace = true } diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 6854ce6614..fac5c1b41c 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -16,6 +16,7 @@ use futures::future::try_join_all; use libp2p::{Multiaddr, PeerId}; use std::fmt; use std::future::Future; +use std::net::SocketAddrV4; use std::path::{Path, PathBuf}; use std::sync::{Arc, Once}; use swap_env::env::{Config as EnvConfig, GetConfig, Mainnet, Testnet}; @@ -436,6 +437,20 @@ mod builder { use super::*; use crate::cli::api::context::EventLoopState; + /// How the app should route its network traffic. + /// + /// Internal, parsed counterpart of [`tauri_bindings::NetworkProxy`] + /// — holds a pre-parsed `SocketAddrV4` instead of a string. + #[derive(Clone, Debug)] + pub enum NetworkProxyConfig { + /// Use the built-in arti-based Tor client. + InternalTor, + /// Route all traffic through an external SOCKS5 proxy at the given IPv4 address. + SystemTorSocks5(SocketAddrV4), + /// No proxy — traffic goes over clearnet. + None, + } + /// A conveniant builder struct for [`Context`]. #[must_use = "ContextBuilder must be built to be useful"] pub struct ContextBuilder { @@ -444,7 +459,7 @@ mod builder { data: Option, is_testnet: bool, json: bool, - tor: bool, + network_proxy: NetworkProxyConfig, enable_monero_tor: bool, tauri_handle: Option, rendezvous_points: Vec<(PeerId, Vec)>, @@ -468,7 +483,7 @@ mod builder { data: None, is_testnet: false, json: false, - tor: false, + network_proxy: NetworkProxyConfig::None, enable_monero_tor: false, tauri_handle: None, rendezvous_points: Vec::new(), @@ -512,9 +527,10 @@ mod builder { self } - /// Whether to initialize a Tor client (default false) - pub fn with_tor(mut self, tor: bool) -> Self { - self.tor = tor; + /// Configures how the app should route its network traffic + /// (default [`NetworkProxyConfig::None`]). + pub fn with_network_proxy(mut self, proxy: NetworkProxyConfig) -> Self { + self.network_proxy = proxy; self } @@ -536,6 +552,18 @@ mod builder { /// /// Context fields are set as early as possible for availability to other parts of the system. pub async fn build(self, context: Arc) -> Result<()> { + // Enable/disable system Tor SOCKS5 routing before any network init. + match &self.network_proxy { + NetworkProxyConfig::SystemTorSocks5(addr) => { + tor_socks5::enable_with_addr(*addr); + } + NetworkProxyConfig::InternalTor | NetworkProxyConfig::None => { + tor_socks5::disable(); + } + } + + let use_internal_tor = matches!(self.network_proxy, NetworkProxyConfig::InternalTor); + let eigenwallet_data_dir = &eigenwallet_data::new(self.is_testnet)?; let base_data_dir = &data::data_dir_from(self.data, self.is_testnet)?; let log_dir = base_data_dir.join("logs"); @@ -583,7 +611,7 @@ mod builder { let future_unbootstrapped_tor_client_rpc_pool = { let tauri_handle = self.tauri_handle.clone(); async move { - let unbootstrapped_tor_client = if self.tor { + let unbootstrapped_tor_client = if use_internal_tor { match create_tor_client(&base_data_dir).await.inspect_err(|err| { tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor"); }) { @@ -878,7 +906,7 @@ mod builder { } } -pub use builder::ContextBuilder; +pub use builder::{ContextBuilder, NetworkProxyConfig}; mod wallet { use super::*; diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index fca1bebd36..cfe0bd65cf 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -5,7 +5,7 @@ use crate::cli::api::tauri_bindings::{ TauriSwapProgressEvent, }; use crate::cli::list_sellers::QuoteWithAddress; -use crate::common::{get_logs, redact}; +use crate::common::{get_logs, http, redact}; use crate::monero::MoneroAddressPool; use crate::monero::wallet_rpc::MoneroDaemon; use crate::network::quote::BidQuote; @@ -27,12 +27,13 @@ use serde_json::json; use std::convert::TryInto; use std::future::Future; use std::path::PathBuf; -use std::sync::{Arc, LazyLock}; +use std::sync::Arc; use std::time::Duration; use swap_core::bitcoin; use swap_core::bitcoin::{CancelTimelock, ExpiredTimelocks, PunishTimelock}; use thiserror::Error; use tokio_util::task::AbortOnDropHandle; +use tor_socks5::Subsystem; use tracing::Instrument; use tracing::Span; use tracing::debug_span; @@ -1732,20 +1733,23 @@ impl CheckMoneroNodeArgs { otherwise => anyhow::bail!(UnknownMoneroNetwork(otherwise.to_string())), }; - static CLIENT: LazyLock = LazyLock::new(|| { - reqwest::Client::builder() - // This function is called very frequently, so we set the timeout to be short - .timeout(Duration::from_secs(5)) - .https_only(false) - .build() - .expect("reqwest client to work") - }); - let Ok(monero_daemon) = MoneroDaemon::from_str(self.url, network) else { return Ok(CheckMoneroNodeResponse { available: false }); }; + let parsed_url = reqwest::Url::parse(&url).context("Failed to parse Monero node URL")?; - match monero_daemon.is_available(&CLIENT).await { + let client = http::configure_http_client( + reqwest::Client::builder() + // This function is called very frequently, so we set the timeout to be short + .timeout(Duration::from_secs(5)) + .https_only(false), + &parsed_url, + Subsystem::MoneroRpc, + )? + .build() + .context("Failed to build Monero node check HTTP client")?; + + match monero_daemon.is_available(&client).await { Ok(available) => Ok(CheckMoneroNodeResponse { available }), Err(e) => { tracing::error!( diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 3679fcd364..b19920fa33 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -1260,6 +1260,23 @@ pub enum MoneroNodeConfig { SingleNode { url: String }, } +/// How the app should route its network traffic. +/// +/// Makes invalid combinations of the previous `use_tor` / `use_system_tor_socks5` +/// / `tor_socks5_address` triple unrepresentable. +#[typeshare] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type", content = "content")] +pub enum NetworkProxy { + /// Use the built-in arti-based Tor client. + InternalTor, + /// Route all traffic through an external SOCKS5 proxy at `address` + /// (e.g. `127.0.0.1:9050` or `10.152.152.10:9050` for Whonix). + SystemTorSocks5 { address: String }, + /// No proxy — traffic goes over clearnet. Onion peers are unreachable. + None, +} + /// This struct contains the settings for the Context #[typeshare] #[derive(Debug, Serialize, Deserialize, Clone)] @@ -1268,12 +1285,22 @@ pub struct TauriSettings { pub monero_node_config: MoneroNodeConfig, /// The URLs of the Electrum RPC servers e.g `["ssl://bitcoin.com:50001", "ssl://backup.com:50001"]` pub electrum_rpc_urls: Vec, - /// Whether to initialize and use a tor client. - pub use_tor: bool, + /// How the app should route its network traffic. + pub network_proxy: NetworkProxy, /// Whether to route Monero wallet traffic through Tor pub enable_monero_tor: bool, /// The list of rendezvous points to connect to pub rendezvous_points: Vec, + /// Whether the DFX (fiat on-ramp) integration is enabled. DFX only speaks + /// clearnet, so this flag gates the integration entirely — independent of + /// proxy mode. When `false`, the backend refuses DFX requests and the UI + /// hides the Buy Monero entry-point. + #[serde(default = "default_allow_dfx_clearnet")] + pub allow_dfx_clearnet: bool, +} + +fn default_allow_dfx_clearnet() -> bool { + true } #[typeshare] @@ -1342,3 +1369,72 @@ impl Drop for ApprovalCleanupGuard { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn network_proxy_serde_round_trip() { + // The frontend relies on the exact `{ "type": "...", "content": ... }` + // shape (typeshare-generated). Any drift in the serde attributes or + // variant names breaks the rpc.ts handshake silently. + let cases = vec![ + NetworkProxy::InternalTor, + NetworkProxy::None, + NetworkProxy::SystemTorSocks5 { + address: "127.0.0.1:9050".to_string(), + }, + ]; + + for original in cases { + let json = serde_json::to_value(&original).expect("serialize NetworkProxy"); + let round_tripped: NetworkProxy = + serde_json::from_value(json.clone()).expect("deserialize NetworkProxy"); + // NetworkProxy is not PartialEq; compare through the JSON surface, + // which is exactly what the frontend contract cares about. + let round_tripped_json = + serde_json::to_value(&round_tripped).expect("re-serialize NetworkProxy"); + assert_eq!(json, round_tripped_json); + } + } + + #[test] + fn network_proxy_tag_and_content_shape() { + // Matches the exact JSON the GUI constructs in rpc.ts. + let internal = serde_json::to_value(NetworkProxy::InternalTor).unwrap(); + assert_eq!(internal, serde_json::json!({ "type": "InternalTor" })); + + let none = serde_json::to_value(NetworkProxy::None).unwrap(); + assert_eq!(none, serde_json::json!({ "type": "None" })); + + let socks = serde_json::to_value(NetworkProxy::SystemTorSocks5 { + address: "10.152.152.10:9050".to_string(), + }) + .unwrap(); + assert_eq!( + socks, + serde_json::json!({ + "type": "SystemTorSocks5", + "content": { "address": "10.152.152.10:9050" }, + }) + ); + } + + #[test] + fn tauri_settings_defaults_allow_dfx_clearnet_when_missing() { + // Old GUI builds won't send `allow_dfx_clearnet`; serde default must + // preserve the previous clearnet-enabled behaviour so upgrades don't + // silently disable the Buy Monero entry-point. + let json = serde_json::json!({ + "monero_node_config": { "type": "Pool" }, + "electrum_rpc_urls": [], + "network_proxy": { "type": "None" }, + "enable_monero_tor": false, + "rendezvous_points": [], + }); + let settings: TauriSettings = + serde_json::from_value(json).expect("TauriSettings without allow_dfx_clearnet"); + assert!(settings.allow_dfx_clearnet); + } +} diff --git a/swap/src/cli/command.rs b/swap/src/cli/command.rs index 37b338aac6..c2c7a5dca7 100644 --- a/swap/src/cli/command.rs +++ b/swap/src/cli/command.rs @@ -14,7 +14,7 @@ use structopt::{StructOpt, clap}; use url::Url; use uuid::Uuid; -use super::api::ContextBuilder; +use super::api::{ContextBuilder, NetworkProxyConfig}; use super::api::request::GetLogsArgs; // See: https://1209k.com/bitcoin-eye/ele.php?chain=btc @@ -156,7 +156,11 @@ async fn apply_defaults( tor, } => { ContextBuilder::new(is_testnet) - .with_tor(tor.enable_tor) + .with_network_proxy(if tor.enable_tor { + NetworkProxyConfig::InternalTor + } else { + NetworkProxyConfig::None + }) .with_bitcoin(bitcoin) .with_monero(monero) .with_data_dir(data) diff --git a/swap/src/cli/transport.rs b/swap/src/cli/transport.rs index 75c481e303..2de572014e 100644 --- a/swap/src/cli/transport.rs +++ b/swap/src/cli/transport.rs @@ -1,31 +1,39 @@ +use std::io; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; use crate::network::transport::authenticate_and_multiplex; use anyhow::Result; use arti_client::TorClient; +use data_encoding::BASE32_NOPAD; +use futures::FutureExt; +use futures::future::BoxFuture; use libp2p::core::muxing::StreamMuxerBox; -use libp2p::core::transport::{Boxed, OptionalTransport}; -use libp2p::{PeerId, Transport, identity}; +use libp2p::core::transport::{ + Boxed, ListenerId, OptionalTransport, TransportError, TransportEvent, +}; +use libp2p::multiaddr::Protocol; +use libp2p::{Multiaddr, PeerId, Transport, identity}; use libp2p::{dns, tcp, websocket}; use libp2p_tor::{AddressConversion, TorTransport}; +use tokio_util::compat::TokioAsyncReadCompatExt; use tor_rtcompat::tokio::TokioRustlsRuntime; -/// Creates the libp2p transport for the swap CLI. -/// -/// The CLI's transport needs the following capabilities: -/// - Establish TCP connections -/// - Resolve DNS entries -/// - Dial websocket addresses (ws), including over Tor -/// - Dial onion-addresses through a running Tor daemon by connecting to the -/// socks5 port. If the port is not given, we will fall back to the regular -/// TCP transport. +/// Create the libp2p transport for the swap CLI. pub fn new( identity: &identity::Keypair, maybe_tor_client: Option>>, ) -> Result> { - // Build the websocket transport first. WsConfig strips the /ws suffix and - // delegates to its inner transport, so we give it a Tor-or-TCP+DNS chain so - // that ws connections are routed over Tor when available. + if maybe_tor_client.is_none() && tor_socks5::is_enabled() { + return new_with_system_socks5(identity); + } + + if maybe_tor_client.is_none() { + return new_clearnet(identity); + } + + // `WsConfig` strips the `/ws` suffix and delegates to its inner transport. let ws_inner_tcp = tcp::tokio::Transport::new(tcp::Config::new().nodelay(true)); let ws_inner_tcp_dns = dns::tokio::Transport::system(ws_inner_tcp)?; let ws_inner_tor: OptionalTransport = match &maybe_tor_client { @@ -38,7 +46,7 @@ pub fn new( let ws_inner = ws_inner_tor.or_transport(ws_inner_tcp_dns); let ws_transport = websocket::WsConfig::new(ws_inner); - // Build the plain Tor-or-TCP+DNS transport for non-websocket addresses. + // Plain transport for non-websocket addresses. let tcp = tcp::tokio::Transport::new(tcp::Config::new().nodelay(true)); let tcp_with_dns = dns::tokio::Transport::system(tcp)?; let maybe_tor_transport: OptionalTransport = match maybe_tor_client { @@ -50,10 +58,198 @@ pub fn new( }; let plain_transport = maybe_tor_transport.or_transport(tcp_with_dns); - // WsConfig only matches addresses ending in /ws or /wss, so it must come - // first — otherwise Tor or TCP would eagerly claim the address (ignoring the - // /ws suffix) and establish a raw connection without a WebSocket handshake. + // Put `WsConfig` first so `/ws` and `/wss` get the WebSocket handshake. + let transport = ws_transport.or_transport(plain_transport).boxed(); + + authenticate_and_multiplex(transport, identity) +} + +/// Clearnet-only transport (no Tor, no SOCKS5). +fn new_clearnet(identity: &identity::Keypair) -> Result> { + let ws_inner_tcp = tcp::tokio::Transport::new(tcp::Config::new().nodelay(true)); + let ws_inner = dns::tokio::Transport::system(ws_inner_tcp)?; + let ws_transport = websocket::WsConfig::new(ws_inner); + + let tcp = tcp::tokio::Transport::new(tcp::Config::new().nodelay(true)); + let plain_transport = dns::tokio::Transport::system(tcp)?; + let transport = ws_transport.or_transport(plain_transport).boxed(); + authenticate_and_multiplex(transport, identity) +} + +/// SOCKS5 transport with direct TCP fallback for local addresses. +fn new_with_system_socks5(identity: &identity::Keypair) -> Result> { + let proxy = tor_socks5::proxy_config(tor_socks5::Subsystem::Libp2p) + .expect("libp2p SOCKS5 proxy config must exist when system Tor is enabled"); + + let local_tcp_plain = tcp::tokio::Transport::new(tcp::Config::new().nodelay(true)); + let local_tcp_ws = tcp::tokio::Transport::new(tcp::Config::new().nodelay(true)); + + let ws_inner = Socks5Transport::new(proxy).or_transport(local_tcp_ws); + let ws_transport = websocket::WsConfig::new(ws_inner); + let plain_transport = Socks5Transport::new(proxy).or_transport(local_tcp_plain); + let transport = ws_transport.or_transport(plain_transport).boxed(); authenticate_and_multiplex(transport, identity) } + +type Socks5Stream = + tokio_util::compat::Compat>; + +struct Socks5Transport { + proxy: tor_socks5::ProxyConfig, +} + +impl Socks5Transport { + fn new(proxy: tor_socks5::ProxyConfig) -> Self { + Self { proxy } + } + + fn extract_target(addr: &Multiaddr) -> Option<(String, u16)> { + let mut iter = addr.iter(); + + let host = match iter.next()? { + Protocol::Onion3(onion) => { + let encoded = BASE32_NOPAD.encode(onion.hash()).to_lowercase(); + return Some((format!("{encoded}.onion"), onion.port())); + } + Protocol::Dns4(host) | Protocol::Dns6(host) => host.into_owned(), + Protocol::Ip4(ip) => ip.to_string(), + Protocol::Ip6(ip) => ip.to_string(), + _ => return None, + }; + + if tor_socks5::is_local_host(&host) { + return None; + } + + let Protocol::Tcp(port) = iter.next()? else { + return None; + }; + Some((host, port)) + } +} + +impl Transport for Socks5Transport { + type Output = Socks5Stream; + type Error = io::Error; + type Dial = BoxFuture<'static, Result>; + type ListenerUpgrade = futures::future::Pending>; + + fn listen_on( + &mut self, + _id: ListenerId, + addr: Multiaddr, + ) -> Result<(), TransportError> { + Err(TransportError::MultiaddrNotSupported(addr)) + } + + fn remove_listener(&mut self, _id: ListenerId) -> bool { + false + } + + fn dial(&mut self, addr: Multiaddr) -> Result> { + let (host, port) = Self::extract_target(&addr) + .ok_or_else(|| TransportError::MultiaddrNotSupported(addr))?; + + let proxy = self.proxy; + + Ok(async move { + let stream = proxy + .connect((host, port)) + .await + .map_err(|e| io::Error::other(e.to_string()))?; + + Ok(stream.compat()) + } + .boxed()) + } + + fn dial_as_listener( + &mut self, + addr: Multiaddr, + ) -> Result> { + self.dial(addr) + } + + fn address_translation(&self, _listen: &Multiaddr, _observed: &Multiaddr) -> Option { + None + } + + fn poll( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn target_of(s: &str) -> Option<(String, u16)> { + let addr = Multiaddr::from_str(s).expect("valid test multiaddr"); + Socks5Transport::extract_target(&addr) + } + + #[test] + fn extract_target_resolves_ip_and_dns_hosts() { + assert_eq!( + target_of("/ip4/1.2.3.4/tcp/9050"), + Some(("1.2.3.4".to_string(), 9050)) + ); + assert_eq!( + target_of("/ip6/2001:db8::1/tcp/80"), + Some(("2001:db8::1".to_string(), 80)) + ); + assert_eq!( + target_of("/dns4/example.com/tcp/443"), + Some(("example.com".to_string(), 443)) + ); + assert_eq!( + target_of("/dns6/example.com/tcp/443"), + Some(("example.com".to_string(), 443)) + ); + } + + #[test] + fn extract_target_skips_local_hosts() { + // Local addresses use the direct TCP fallback. + assert_eq!(target_of("/ip4/127.0.0.1/tcp/9050"), None); + assert_eq!(target_of("/ip4/192.168.1.1/tcp/80"), None); + assert_eq!(target_of("/ip6/::1/tcp/80"), None); + assert_eq!(target_of("/dns4/localhost/tcp/9050"), None); + } + + #[test] + fn extract_target_rejects_non_tcp_second_protocol() { + // Only TCP targets are supported. + assert_eq!(target_of("/ip4/1.2.3.4/udp/9050"), None); + } + + #[test] + fn extract_target_rejects_unsupported_leading_protocol() { + // Leading `/p2p/...` has no dial target for SOCKS5. + assert_eq!( + target_of("/p2p/12D3KooWGQmdpzHXCqLno4mMxWXKNFQHASBeF99gTm2JR8Vu5Bdc"), + None + ); + } + + #[test] + fn extract_target_encodes_onion3() { + // Use a fixed onion hash to keep the expected host stable. + let hash = [0u8; 35]; + let onion = libp2p::multiaddr::Onion3Addr::from((hash, 1234)); + let addr = Multiaddr::empty().with(Protocol::Onion3(onion)); + + let (host, port) = Socks5Transport::extract_target(&addr).expect("onion target"); + assert_eq!(port, 1234); + assert!(host.ends_with(".onion"), "host must end in .onion, got {host}"); + assert_eq!(host, host.to_lowercase(), "onion host must be lowercase"); + let stripped = host.strip_suffix(".onion").unwrap(); + assert_eq!(stripped.len(), 56, "base32 of 35 bytes must be 56 chars"); + } +} diff --git a/swap/src/common/http.rs b/swap/src/common/http.rs new file mode 100644 index 0000000000..6cd01e3fb3 --- /dev/null +++ b/swap/src/common/http.rs @@ -0,0 +1,123 @@ +use anyhow::{Context, Result}; +use reqwest::Url; +use std::time::Duration; +use tor_socks5::Subsystem; + +/// Build a `reqwest::Client` for `url`. +/// +/// Local addresses bypass the proxy. +pub fn build_http_client( + url: &Url, + timeout: Duration, + subsystem: Subsystem, + user_agent: Option<&str>, +) -> Result { + let mut builder = reqwest::Client::builder().timeout(timeout); + if let Some(user_agent) = user_agent { + builder = builder.user_agent(user_agent); + } + configure_http_client(builder, url, subsystem)? + .build() + .context("Failed to build HTTP client") +} + +pub fn configure_http_client( + mut builder: reqwest::ClientBuilder, + url: &Url, + subsystem: Subsystem, +) -> Result { + if should_bypass_proxy(url) { + tracing::debug!(%url, "Bypassing system Tor SOCKS5 for local or LAN address"); + return Ok(builder); + } + + if let Some(proxy) = tor_socks5::proxy_config(subsystem) { + tracing::debug!(%url, proxy = %proxy.url(), "Using system Tor SOCKS5 for HTTP request"); + + builder = builder.proxy( + proxy + .reqwest_proxy() + .context("Failed to configure system Tor SOCKS5 proxy")?, + ); + } + + Ok(builder) +} + +fn should_bypass_proxy(url: &Url) -> bool { + match url.host_str() { + None => false, + Some(host) => tor_socks5::is_local_host(host), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, SocketAddrV4}; + use std::sync::Mutex; + + /// Tests that toggle `tor_socks5` must not run in parallel. + static PROXY_STATE_LOCK: Mutex<()> = Mutex::new(()); + + fn url(s: &str) -> Url { + Url::parse(s).expect("valid test URL") + } + + #[test] + fn should_bypass_proxy_for_local_urls() { + assert!(should_bypass_proxy(&url("http://localhost:1234"))); + assert!(should_bypass_proxy(&url("http://127.0.0.1:9050"))); + assert!(should_bypass_proxy(&url("http://10.0.0.1"))); + assert!(should_bypass_proxy(&url("http://192.168.1.1:80"))); + assert!(should_bypass_proxy(&url("https://myhost.local"))); + } + + #[test] + fn should_not_bypass_proxy_for_remote_urls() { + assert!(!should_bypass_proxy(&url("https://example.com"))); + assert!(!should_bypass_proxy(&url("https://api.coingecko.com/api"))); + // Bare single-label hosts stay on the proxied path. + assert!(!should_bypass_proxy(&url("http://intranet"))); + } + + #[test] + fn build_http_client_is_infallible_for_both_modes() { + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + + // Building without a proxy must succeed. + tor_socks5::disable(); + assert!( + build_http_client( + &url("https://example.com"), + Duration::from_secs(5), + Subsystem::Http, + None, + ) + .is_ok() + ); + + // Both proxied and bypassed requests must build. + tor_socks5::enable_with_addr(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 9050)); + assert!( + build_http_client( + &url("https://example.com"), + Duration::from_secs(5), + Subsystem::MoneroRpc, + Some("eigenwallet-test/0.0"), + ) + .is_ok() + ); + assert!( + build_http_client( + &url("http://127.0.0.1:18081"), + Duration::from_secs(5), + Subsystem::MoneroRpc, + None, + ) + .is_ok() + ); + + tor_socks5::disable(); + } +} diff --git a/swap/src/common/mod.rs b/swap/src/common/mod.rs index 1f56bf1803..b26b789d67 100644 --- a/swap/src/common/mod.rs +++ b/swap/src/common/mod.rs @@ -1,20 +1,26 @@ +pub mod http; pub mod tor; pub mod tracing_util; -use anyhow::anyhow; +use anyhow::{Context, anyhow}; use std::{collections::HashMap, future::Future, path::PathBuf, time::Duration}; use tokio::{ fs::{File, read_dir}, io::{AsyncBufReadExt, BufReader}, }; +use tor_socks5::Subsystem; use uuid::Uuid; const LATEST_RELEASE_URL: &str = "https://github.com/eigenwallet/core/releases/latest"; /// Check the latest release from GitHub and warn if we are not on the latest version. pub async fn warn_if_outdated(current_version: &str) -> anyhow::Result<()> { + let release_url = + reqwest::Url::parse(LATEST_RELEASE_URL).context("Failed to parse latest release URL")?; + let client = http::build_http_client(&release_url, Duration::from_secs(20), Subsystem::Http, None)?; + // Visit the Github releases page and check which url we are redirected to - let response = reqwest::get(LATEST_RELEASE_URL).await?; + let response = client.get(release_url).send().await?; let download_url = response.url(); let segments = download_url diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index 0a3a2de3b2..e777941ef4 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -442,6 +442,7 @@ async fn init_test_wallets( hostname: "127.0.0.1".to_string(), port: monerod_port, ssl: false, + proxy_address: None, }; let wallets = Wallets::new( diff --git a/tor-socks5/Cargo.toml b/tor-socks5/Cargo.toml new file mode 100644 index 0000000000..ee94203d9b --- /dev/null +++ b/tor-socks5/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tor-socks5" +version = "0.1.0" +edition = "2024" + +[dependencies] +reqwest = { workspace = true, optional = true } +tokio = { workspace = true, features = ["net"] } +tokio-socks = { workspace = true } +tracing = { workspace = true } + +[features] +reqwest = ["dep:reqwest"] + +[lints] +workspace = true diff --git a/tor-socks5/src/lib.rs b/tor-socks5/src/lib.rs new file mode 100644 index 0000000000..ade55e2cb4 --- /dev/null +++ b/tor-socks5/src/lib.rs @@ -0,0 +1,364 @@ +//! Shared SOCKS5 settings for the current process. +//! +//! Store the proxy address once during startup and reconnect after changes. +//! [`proxy_config`] includes per-subsystem credentials for stream isolation. +//! [`proxy_addr`] is for APIs that only accept `host:port`. +//! IPv4 only. + +use std::io::{Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddrV4, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; + +/// Default Tor SOCKS5 proxy address (standard Tor daemon port). +pub const DEFAULT_ADDR: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 9050); + +const PROBE_TIMEOUT: Duration = Duration::from_millis(250); + +static ENABLED: AtomicBool = AtomicBool::new(false); +/// Packed IPv4 address and port: `(ip as u64) << 16 | port`. +static ADDR: AtomicU64 = AtomicU64::new((0x7f00_0001_u64 << 16) | 9050); + +fn pack(addr: SocketAddrV4) -> u64 { + ((u32::from(*addr.ip()) as u64) << 16) | (addr.port() as u64) +} + +fn unpack(value: u64) -> SocketAddrV4 { + let ip = Ipv4Addr::from((value >> 16) as u32); + let port = value as u16; + SocketAddrV4::new(ip, port) +} + +/// Returns the currently configured proxy address. +pub fn current_addr() -> SocketAddrV4 { + unpack(ADDR.load(Ordering::Relaxed)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Subsystem { + Http, + Updater, + Electrum, + MoneroRpc, + Bitcoin, + Libp2p, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProxyConfig { + pub addr: SocketAddrV4, + /// Tor uses `(username, password)` as the isolation key. + pub username: &'static str, + pub password: &'static str, +} + +impl ProxyConfig { + pub fn url(self) -> String { + format!("socks5h://{}:{}@{}", self.username, self.password, self.addr) + } + + /// Build a `reqwest::Proxy` for this SOCKS5 endpoint. + #[cfg(feature = "reqwest")] + pub fn reqwest_proxy(self) -> reqwest::Result { + reqwest::Proxy::all(self.url()) + } + + /// Dial `target` through the SOCKS5 proxy. + pub async fn connect<'a, T>( + self, + target: T, + ) -> Result, tokio_socks::Error> + where + T: tokio_socks::IntoTargetAddr<'a>, + { + tokio_socks::tcp::Socks5Stream::connect_with_password( + self.addr, + target, + self.username, + self.password, + ) + .await + } +} + +fn isolation_token(subsystem: Subsystem) -> &'static str { + match subsystem { + Subsystem::Http => "http", + Subsystem::Updater => "updater", + Subsystem::Electrum => "electrum", + Subsystem::MoneroRpc => "monero", + Subsystem::Bitcoin => "bitcoin", + Subsystem::Libp2p => "libp2p", + } +} + +impl Subsystem { + /// Build a SOCKS5 URL for `addr` without reading global state. + pub fn proxy_url_for(self, addr: SocketAddrV4) -> String { + let token = isolation_token(self); + ProxyConfig { + addr, + username: token, + password: token, + } + .url() + } +} + +/// Enable SOCKS5 routing on [`DEFAULT_ADDR`]. +pub fn enable() { + enable_with_addr(DEFAULT_ADDR); +} + +/// Enable SOCKS5 routing on `addr`. +/// +/// The `Release` store on `ENABLED` orders the preceding `ADDR` update. +pub fn enable_with_addr(addr: SocketAddrV4) { + ADDR.store(pack(addr), Ordering::Relaxed); + ENABLED.store(true, Ordering::Release); + tracing::info!(proxy = %current_addr(), "System Tor SOCKS5 proxy enabled"); +} + +/// Disable SOCKS5 routing. +pub fn disable() { + ENABLED.store(false, Ordering::Release); + tracing::info!("System Tor SOCKS5 proxy disabled"); +} + +/// Return whether SOCKS5 routing is enabled. +pub fn is_enabled() -> bool { + ENABLED.load(Ordering::Acquire) +} + +/// Return the subsystem-specific proxy settings, if enabled. +pub fn proxy_config(subsystem: Subsystem) -> Option { + let token = isolation_token(subsystem); + + is_enabled().then_some(ProxyConfig { + addr: current_addr(), + username: token, + password: token, + }) +} + +/// Return the proxy socket address, if enabled. +/// +/// Use this only for APIs that accept `host:port` without SOCKS5 credentials. +pub fn proxy_addr() -> Option { + is_enabled().then_some(current_addr()) +} + +/// Probe an IPv4 `ip:port` string and return whether a SOCKS5 server answers. +pub fn probe_addr_str(address: &str) -> bool { + match address.parse::() { + Ok(addr) => probe_addr(addr), + Err(_) => false, + } +} + +/// Send a SOCKS5 greeting and require a SOCKS5 reply. +fn probe_addr(addr: SocketAddrV4) -> bool { + let addr_str = addr.to_string(); + let mut stream = match TcpStream::connect_timeout(&addr.into(), PROBE_TIMEOUT) { + Ok(s) => s, + Err(error) => { + tracing::debug!( + proxy = %addr_str, + %error, + "System Tor SOCKS5 proxy unreachable", + ); + return false; + } + }; + + let _ = stream.set_read_timeout(Some(PROBE_TIMEOUT)); + let _ = stream.set_write_timeout(Some(PROBE_TIMEOUT)); + + if stream.write_all(&[0x05, 0x01, 0x00]).is_err() { + tracing::debug!(proxy = %addr_str, "Failed to send SOCKS5 greeting"); + return false; + } + + let mut buf = [0u8; 2]; + if stream.read_exact(&mut buf).is_err() { + tracing::debug!(proxy = %addr_str, "Failed to read SOCKS5 response"); + return false; + } + + if buf != [0x05, 0x00] { + tracing::debug!( + proxy = %addr_str, + response = ?buf, + "Not a SOCKS5 server (unexpected handshake response)", + ); + return false; + } + + true +} + +/// Return whether `host` should bypass the proxy. +/// +/// Bare hostnames without a dot are not treated as local. +pub fn is_local_host(host: &str) -> bool { + let lower = host.to_ascii_lowercase(); + + if lower == "localhost" || lower.ends_with(".local") { + return true; + } + + match host.parse::() { + Ok(IpAddr::V4(ip)) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + Ok(IpAddr::V6(ip)) => { + ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() + } + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::sync::Mutex; + use std::thread; + + static PROXY_STATE_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn local_hosts_are_detected() { + assert!(is_local_host("localhost")); + assert!(is_local_host("LOCALHOST")); + assert!(is_local_host("myhost.local")); + assert!(is_local_host("127.0.0.1")); + assert!(is_local_host("192.168.1.1")); + assert!(is_local_host("10.0.0.1")); + assert!(is_local_host("169.254.1.1")); + assert!(is_local_host("::1")); + } + + #[test] + fn remote_hosts_are_not_local() { + assert!(!is_local_host("example.com")); + assert!(!is_local_host("8.8.8.8")); + assert!(!is_local_host("2001:db8::1")); + assert!(!is_local_host("node.monero.onion")); + } + + #[test] + fn bare_hostnames_are_not_local() { + // Keep bare names on the proxied path. + assert!(!is_local_host("singleword")); + assert!(!is_local_host("intranet")); + assert!(!is_local_host("wiki")); + } + + #[test] + fn enable_disable_toggle() { + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + + disable(); + assert!(!is_enabled()); + assert!(proxy_config(Subsystem::Http).is_none()); + assert!(proxy_addr().is_none()); + + enable(); + assert!(is_enabled()); + assert_eq!( + proxy_config(Subsystem::Http).map(|proxy| proxy.url()), + Some("socks5h://http:http@127.0.0.1:9050".to_string()) + ); + assert_eq!(proxy_addr(), Some(current_addr())); + + disable(); + assert!(!is_enabled()); + } + + #[test] + fn pack_unpack_roundtrip() { + let cases = [ + SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 9050), + SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), 0), + SocketAddrV4::new(Ipv4Addr::new(255, 255, 255, 255), 65535), + SocketAddrV4::new(Ipv4Addr::new(10, 152, 152, 10), 9050), + ]; + for addr in cases { + assert_eq!(unpack(pack(addr)), addr); + } + } + + #[test] + fn subsystem_credentials_are_isolated() { + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + + enable(); + + let http = proxy_config(Subsystem::Http).expect("http proxy config"); + let updater = proxy_config(Subsystem::Updater).expect("updater proxy config"); + + assert_eq!(http.addr, current_addr()); + assert_eq!(updater.addr, current_addr()); + assert_ne!(http.url(), updater.url()); + + disable(); + } + + #[test] + fn probe_addr_str_rejects_malformed_input() { + // Invalid IPv4 `ip:port` strings must fail locally. + assert!(!probe_addr_str("")); + assert!(!probe_addr_str("not-an-addr")); + assert!(!probe_addr_str("127.0.0.1")); // missing port + assert!(!probe_addr_str("localhost:9050")); // hostname, not IPv4 + assert!(!probe_addr_str("[::1]:9050")); // IPv6 not supported + } + + #[test] + fn probe_addr_rejects_non_socks5_tcp_service() { + // Reject TCP services that do not speak SOCKS5. + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + let addr = match listener.local_addr().expect("local_addr") { + std::net::SocketAddr::V4(v4) => v4, + std::net::SocketAddr::V6(_) => panic!("expected IPv4 ephemeral bind"), + }; + + let server = thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Reply with non-SOCKS5 bytes. + let _ = stream.write_all(&[0x00, 0x00]); + } + }); + + assert!(!probe_addr(addr)); + let _ = server.join(); + } + + #[test] + fn subsystem_proxy_url_for_does_not_require_enabled() { + // This path must not depend on `enable()`. + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + disable(); + + let addr = SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 9050); + assert_eq!( + Subsystem::Updater.proxy_url_for(addr), + "socks5h://updater:updater@127.0.0.1:9050" + ); + assert!(!is_enabled()); + } + + #[test] + fn enable_with_addr_updates_current_addr() { + let _guard = PROXY_STATE_LOCK.lock().unwrap(); + + let whonix = SocketAddrV4::new(Ipv4Addr::new(10, 152, 152, 10), 9050); + enable_with_addr(whonix); + assert_eq!(current_addr(), whonix); + assert_eq!(proxy_addr(), Some(whonix)); + + disable(); + // `disable()` only clears ENABLED — the last address stays cached. + assert_eq!(current_addr(), whonix); + assert!(proxy_addr().is_none()); + } +} From b658afaa8d86e552f034851a0c6c3b386a017f57 Mon Sep 17 00:00:00 2001 From: fleebicorn <278484091+fleebicorn@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:17:24 +0000 Subject: [PATCH 2/3] wire tor socks settings through the gui and tauri Replace the old Tor toggle with persisted proxy settings in the GUI. Pass the selected proxy mode and SOCKS5 address into Tauri so the backend and updater use the same settings. --- src-gui/src/renderer/api.ts | 139 +++--- .../modal/updater/UpdaterDialog.tsx | 49 +- .../components/pages/help/SettingsBox.tsx | 422 +++++++++++++++--- .../pages/monero/components/DFXWidget.tsx | 10 + src-gui/src/renderer/rpc.ts | 28 +- src-gui/src/renderer/store/storeRenderer.ts | 19 +- src-gui/src/store/features/settingsSlice.ts | 36 +- src-tauri/Cargo.toml | 10 + src-tauri/build.rs | 122 +++++ src-tauri/src/commands.rs | 157 ++++++- src-tauri/src/http_client.rs | 17 + src-tauri/src/lib.rs | 9 + swap/src/cli/api/tauri_bindings.rs | 21 +- 13 files changed, 888 insertions(+), 151 deletions(-) create mode 100644 src-tauri/src/http_client.rs diff --git a/src-gui/src/renderer/api.ts b/src-gui/src/renderer/api.ts index 974a0923e4..0c503d2e1c 100644 --- a/src-gui/src/renderer/api.ts +++ b/src-gui/src/renderer/api.ts @@ -5,6 +5,7 @@ // - and to submit feedback // - fetch currency rates from CoinGecko +import { invoke as invokeUnsafe } from "@tauri-apps/api/core"; import { Alert, AttachmentInput, Message } from "models/apiModel"; import { store } from "./store/storeRenderer"; import { @@ -19,9 +20,45 @@ import { setConversation } from "store/features/conversationsSlice"; const PUBLIC_REGISTRY_API_BASE_URL = "https://api.unstoppableswap.net"; +interface HttpResponse { + status: number; + body: string; +} + +async function httpGet(url: string): Promise { + return invokeUnsafe("http_get", { + args: { url }, + }) as Promise; +} + +async function httpPostJson( + url: string, + body: unknown, +): Promise { + return invokeUnsafe("http_post_json", { + args: { url, body: JSON.stringify(body) }, + }) as Promise; +} + +function ensureSuccessfulResponse(response: HttpResponse, url: string): void { + if (response.status >= 200 && response.status < 300) { + return; + } + + throw new Error( + `Request to ${url} failed. Status: ${response.status}. Body: ${response.body}`, + ); +} + +function parseJsonResponse(response: HttpResponse, url: string): T { + ensureSuccessfulResponse(response, url); + return JSON.parse(response.body) as T; +} + async function fetchAlertsViaHttp(): Promise { - const response = await fetch(`${PUBLIC_REGISTRY_API_BASE_URL}/api/alerts`); - return (await response.json()) as Alert[]; + const url = `${PUBLIC_REGISTRY_API_BASE_URL}/api/alerts`; + const response = await httpGet(url); + return parseJsonResponse(response, url); } export async function submitFeedbackViaHttp( @@ -35,42 +72,20 @@ export async function submitFeedbackViaHttp( attachments: attachments || [], // Ensure attachments is always an array }; - const response = await fetch( - `${PUBLIC_REGISTRY_API_BASE_URL}/api/submit-feedback`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestPayload), // Send the corrected structure - }, + const url = `${PUBLIC_REGISTRY_API_BASE_URL}/api/submit-feedback`; + const response = await httpPostJson( + url, + requestPayload, // Send the corrected structure ); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to submit feedback. Status: ${response.status}. Body: ${errorBody}`, - ); - } - - const responseBody = (await response.json()) as Response; - return responseBody; + return parseJsonResponse(response, url); } export async function fetchFeedbackMessagesViaHttp( feedbackId: string, ): Promise { - const response = await fetch( - `${PUBLIC_REGISTRY_API_BASE_URL}/api/feedback/${feedbackId}/messages`, - ); - if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to fetch messages for feedback ${feedbackId}. Status: ${response.status}. Body: ${errorBody}`, - ); - } - // Assuming the response is directly the Message[] array including attachments - return (await response.json()) as Message[]; + const url = `${PUBLIC_REGISTRY_API_BASE_URL}/api/feedback/${feedbackId}/messages`; + const response = await httpGet(url); + return parseJsonResponse(response, url); } export async function appendFeedbackMessageViaHttp( @@ -86,44 +101,38 @@ export async function appendFeedbackMessageViaHttp( attachments: attachments || [], // Ensure attachments is always an array }; - const response = await fetch( - `${PUBLIC_REGISTRY_API_BASE_URL}/api/append-feedback-message`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), // Send new structure - }, + const url = `${PUBLIC_REGISTRY_API_BASE_URL}/api/append-feedback-message`; + const response = await httpPostJson( + url, + body, // Send new structure ); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to append message for feedback ${feedbackId}. Status: ${response.status}. Body: ${errorBody}`, - ); - } - - const responseBody = (await response.json()) as Response; - return responseBody; + return parseJsonResponse(response, url); } async function fetchCurrencyPrice( currency: string, fiatCurrency: FiatCurrency, ): Promise { - const response = await fetch( - `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=${fiatCurrency.toLowerCase()}`, + const url = `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=${fiatCurrency.toLowerCase()}`; + const response = await httpGet(url); + const data = parseJsonResponse>>( + response, + url, ); - const data = await response.json(); return data[currency][fiatCurrency.toLowerCase()]; } async function fetchXmrBtcRate(): Promise { - const response = await fetch( - "https://api.kraken.com/0/public/Ticker?pair=XMRXBT", - ); - const data = await response.json(); + const url = "https://api.kraken.com/0/public/Ticker?pair=XMRXBT"; + const response = await httpGet(url); + const data = parseJsonResponse<{ + error?: string[]; + result: { + XXMRXXBT: { + c: [string, string]; + }; + }; + }>(response, url); if (data.error && data.error.length > 0) { throw new Error(`Kraken API error: ${data.error[0]}`); @@ -154,17 +163,25 @@ export async function updateRates(): Promise { try { const xmrBtcRate = await fetchXmrBtcRate(); store.dispatch(setXmrBtcRate(xmrBtcRate)); + } catch (error) { + logger.error(error, "Error fetching XMR/BTC market rate"); + } + try { const btcPrice = await fetchBtcPrice(settings.fiatCurrency); store.dispatch(setBtcPrice(btcPrice)); + } catch (error) { + logger.error(error, `Error fetching BTC price in ${settings.fiatCurrency}`); + } + try { const xmrPrice = await fetchXmrPrice(settings.fiatCurrency); store.dispatch(setXmrPrice(xmrPrice)); - - logger.info(`Fetched rates for ${settings.fiatCurrency}`); } catch (error) { - logger.error(error, "Error fetching rates"); + logger.error(error, `Error fetching XMR price in ${settings.fiatCurrency}`); } + + logger.info(`Finished rate update for ${settings.fiatCurrency}`); } /** diff --git a/src-gui/src/renderer/components/modal/updater/UpdaterDialog.tsx b/src-gui/src/renderer/components/modal/updater/UpdaterDialog.tsx index 05f6a7d53e..393169c1b4 100644 --- a/src-gui/src/renderer/components/modal/updater/UpdaterDialog.tsx +++ b/src-gui/src/renderer/components/modal/updater/UpdaterDialog.tsx @@ -16,10 +16,29 @@ import SystemUpdateIcon from "@mui/icons-material/SystemUpdate"; import { check, Update, DownloadEvent } from "@tauri-apps/plugin-updater"; import { useSnackbar } from "notistack"; import { relaunch } from "@tauri-apps/plugin-process"; +import { invoke as invokeUnsafe } from "@tauri-apps/api/core"; +import { store } from "renderer/store/storeRenderer"; +import { NetworkProxyMode } from "store/features/settingsSlice"; const GITHUB_RELEASES_URL = "https://github.com/eigenwallet/core/releases"; const HOMEPAGE_URL = "https://unstoppableswap.net/"; +// The updater runs before backend context init, so build the proxy URL +// from persisted settings via Tauri instead of backend state. +async function getSystemTorProxyUrl(): Promise { + const settings = store.getState().settings; + if (settings.networkProxyMode !== NetworkProxyMode.TorSocks) { + return null; + } + const address = settings.torSocksAddress; + if (address === null || address === "") { + throw new Error( + "Tor Socks proxy is selected but no address is configured. Enter an IPv4 address (e.g. 127.0.0.1:9050) in Settings.", + ); + } + return invokeUnsafe("get_updater_proxy_url", { address }); +} + interface DownloadProgress { contentLength: number | null; downloadedBytes: number; @@ -63,17 +82,35 @@ export default function UpdaterDialog() { const { enqueueSnackbar } = useSnackbar(); useEffect(() => { - // Check for updates when component mounts - check() - .then((updateResponse) => { + let cancelled = false; + + void (async () => { + try { + const proxy = await getSystemTorProxyUrl(); + const updateResponse = await check( + proxy === null ? undefined : { proxy }, + ); + + if (cancelled) { + return; + } + console.log("updateResponse", updateResponse); setAvailableUpdate(updateResponse); - }) - .catch((err) => { + } catch (err) { + if (cancelled) { + return; + } + enqueueSnackbar(`Failed to check for updates: ${err}`, { variant: "error", }); - }); + } + })(); + + return () => { + cancelled = true; + }; }, [enqueueSnackbar]); // If no update is available, don't render the dialog diff --git a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx index 4672c25cc9..d28ab8983d 100644 --- a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx +++ b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx @@ -1,4 +1,5 @@ import { + Alert, Table, TableBody, TableCell, @@ -20,6 +21,7 @@ import { useTheme, Switch, SelectChangeEvent, + TextField, ToggleButton, ToggleButtonGroup, } from "@mui/material"; @@ -34,8 +36,10 @@ import { setFetchFiatPrices, setFiatCurrency, setTheme, - setTorEnabled, + setNetworkProxyMode, + setTorSocksAddress, setEnableMoneroTor, + setAllowDfxClearnet, setUseMoneroRpcPool, setMoneroRedeemPolicy, setMoneroRedeemAddress, @@ -43,12 +47,13 @@ import { setBitcoinRefundPolicy, RedeemPolicy, RefundPolicy, + NetworkProxyMode, } from "store/features/settingsSlice"; import { Blockchain, Network } from "store/types"; import { useAppDispatch, useNodes, useSettings } from "store/hooks"; import ValidatedTextField from "renderer/components/other/ValidatedTextField"; import HelpIcon from "@mui/icons-material/HelpOutline"; -import { ReactNode, useState } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { Theme } from "renderer/components/theme"; import { Add, @@ -58,6 +63,7 @@ import { HourglassEmpty, Refresh, } from "@mui/icons-material"; +import { invoke as invokeUnsafe } from "@tauri-apps/api/core"; import { getNetwork } from "store/config"; import { currencySymbol } from "utils/formatUtils"; @@ -74,6 +80,150 @@ import DonationTipDialog, { const PLACEHOLDER_ELECTRUM_RPC_URL = "ssl://blockstream.info:700"; const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081"; +/** Returns true when the IP part of an "ip:port" string is the IPv4 loopback. */ +const isSocksAddressLocalhost = (val: string): boolean => { + const ip = val.slice(0, val.lastIndexOf(":")); + return ip === "127.0.0.1"; +}; + +/** + * Result of parsing a user-entered SOCKS5 address as it's being typed. + * + * The phases track how far the user has progressed through `ipv4:port`: + * `empty` → `partialIp` → `ipComplete` → `awaitingPort` → `complete`, with + * `invalid` for anything that cannot be fixed by typing more characters + * (e.g. octet > 255, leading zero, port > 65535). + * + * Only `complete` triggers a SOCKS5 probe. Other phases drive the + * helper-text / error UI so the user sees *why* input is incomplete. + */ +type SocksAddressParse = + | { phase: "empty" } + | { phase: "partialIp"; hint: string } + | { phase: "ipComplete"; hint: string } + | { phase: "awaitingPort"; hint: string } + | { phase: "complete" } + | { phase: "invalid"; error: string }; + +/** + * Validates a single IPv4 octet as the user types it. + * + * `partial` means "so far so good, may accept more digits" (e.g. "1", "25"). + * `complete` means the octet is fully specified (e.g. "192", "0", "255"). + * Rejects leading zeros ("01", "001") to match Rust's `SocketAddrV4::from_str`. + */ +const classifyOctet = ( + s: string, +): { kind: "empty" } | { kind: "partial" } | { kind: "complete" } | { kind: "invalid" } => { + if (s === "") return { kind: "empty" }; + if (!/^\d+$/.test(s)) return { kind: "invalid" }; + if (s.length > 3) return { kind: "invalid" }; + if (s.length > 1 && s.startsWith("0")) return { kind: "invalid" }; + const n = parseInt(s, 10); + if (n > 255) return { kind: "invalid" }; + // An octet of 1–2 digits could still grow (e.g. "2" → "25" → "255"), + // so we call those "partial"; 3 digits are final. + if (s.length === 3) return { kind: "complete" }; + return { kind: "partial" }; +}; + +/** + * Parse user input through each phase of a `SocketAddrV4`. Strictly matches + * Rust's `SocketAddrV4::from_str` so the frontend and backend never disagree: + * rejects leading zeros, octets > 255, and ports outside 1–65535. + */ +const parseSocks5Address = (raw: string): SocksAddressParse => { + if (raw === "") return { phase: "empty" }; + + const colonCount = (raw.match(/:/g) ?? []).length; + if (colonCount > 1) { + return { phase: "invalid", error: "Only one ':' is allowed — use ipv4:port" }; + } + + const [ipPart, portPart] = raw.includes(":") ? raw.split(":") : [raw, null]; + + const octets = ipPart.split("."); + if (octets.length > 4) { + return { phase: "invalid", error: "IPv4 has exactly 4 octets" }; + } + + // Classify each octet that's been typed so far. + let allOctetsComplete = true; + for (let i = 0; i < octets.length; i++) { + const result = classifyOctet(octets[i]); + if (result.kind === "invalid") { + return { + phase: "invalid", + error: `Invalid octet "${octets[i]}" — each octet must be 0–255 with no leading zeros`, + }; + } + // Non-last octets must be complete (otherwise the dot is premature). + if (i < octets.length - 1 && result.kind !== "complete" && result.kind !== "partial") { + return { phase: "invalid", error: "Each '.' must follow an octet" }; + } + // `partial` (1-2 digits) is already a valid octet numerically; only an + // empty trailing octet (e.g. "127.0.0.") means the IP is unfinished. + if (result.kind === "empty") allOctetsComplete = false; + } + + const hasAllFourOctets = octets.length === 4; + + if (!hasAllFourOctets || !allOctetsComplete) { + // Still building up the IP. + if (portPart !== null) { + return { + phase: "invalid", + error: "Finish the IPv4 address before adding ':port'", + }; + } + return { + phase: "partialIp", + hint: hasAllFourOctets + ? "Finish the last octet" + : `Enter ${4 - octets.length} more octet${octets.length === 3 ? "" : "s"}`, + }; + } + + // IP is complete. Now handle the port side. + if (portPart === null) { + return { phase: "ipComplete", hint: "Type ':' then the port" }; + } + if (portPart === "") { + return { phase: "awaitingPort", hint: "Enter the port (1–65535)" }; + } + if (!/^\d+$/.test(portPart)) { + return { phase: "invalid", error: "Port must be digits only" }; + } + if (portPart.length > 1 && portPart.startsWith("0")) { + return { phase: "invalid", error: "Port must not have leading zeros" }; + } + const port = parseInt(portPart, 10); + if (port < 1 || port > 65535) { + return { phase: "invalid", error: "Port must be between 1 and 65535" }; + } + if (portPart.length > 5) { + return { phase: "invalid", error: "Port must be at most 5 digits" }; + } + return { phase: "complete" }; +}; + +/** True once the string is a fully formed ipv4:port ready for a probe. */ +const isValidSocksAddress = (val: string): boolean => + parseSocks5Address(val).phase === "complete"; + +/** + * Runs a single SOCKS5 handshake probe via the `check_socks5_address` Tauri + * command. Failures (parse error, TCP error, unexpected handshake response) + * all collapse into `false` — callers only need to know reachable/not. + */ +async function probeSocks5(address: string): Promise { + try { + return await invokeUnsafe("check_socks5_address", { address }); + } catch { + return false; + } +} + /** * The settings box, containing the settings for the GUI. */ @@ -99,8 +249,7 @@ export default function SettingsBox() { - - + @@ -704,62 +853,231 @@ function NodeTable({ ); } -export function TorSettings() { +function NetworkProxySetting() { const dispatch = useAppDispatch(); - const torEnabled = useSettings((settings) => settings.enableTor); - const handleChange = (event: React.ChangeEvent) => - dispatch(setTorEnabled(event.target.checked)); - const status = (state: boolean) => (state === true ? "enabled" : "disabled"); + const networkProxyMode = useSettings((s) => s.networkProxyMode); + const torSocksAddress = useSettings((s) => s.torSocksAddress); + const enableMoneroTor = useSettings((s) => s.enableMoneroTor); + const allowDfxClearnet = useSettings((s) => s.allowDfxClearnet); - return ( - - - - + const [addrInput, setAddrInput] = useState(torSocksAddress ?? ""); + const [probeStatus, setProbeStatus] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); - - - - - ); -} + // Keep addrInput in sync with persisted state (e.g. after rehydration) + useEffect(() => { + setAddrInput(torSocksAddress ?? ""); + }, [torSocksAddress]); -/** - * A setting that allows you to enable or disable routing Monero wallet traffic through Tor. - * This setting is only visible when Tor is enabled. - */ -function MoneroTorSettings() { - const dispatch = useAppDispatch(); - const torEnabled = useSettings((settings) => settings.enableTor); - const enableMoneroTor = useSettings((settings) => settings.enableMoneroTor); + const handleAddrChange = (raw: string) => { + // Strip any character that cannot appear in an IPv4 ip:port address. + const val = raw.replace(/[^0-9.:]/g, ""); + setAddrInput(val); + if (val === "") { + dispatch(setTorSocksAddress(null)); + setProbeStatus(null); + } else if (isValidSocksAddress(val)) { + dispatch(setTorSocksAddress(val)); + } + }; - const handleChange = (event: React.ChangeEvent) => - dispatch(setEnableMoneroTor(event.target.checked)); + const parsed = parseSocks5Address(addrInput); - // Hide this setting if Tor is disabled entirely - if (!torEnabled) { - return null; - } + // Debounced auto-probe: wait until the user stops typing before firing a + // SOCKS5 handshake, so each keystroke doesn't trigger its own TCP connect. + // Only fires once the address is fully formed — `ipComplete`, + // `awaitingPort`, and `partialIp` phases do not hit the network. + useEffect(() => { + if (networkProxyMode !== NetworkProxyMode.TorSocks || parsed.phase !== "complete") { + setProbeStatus(null); + return; + } + + let cancelled = false; + const timer = setTimeout(async () => { + setIsRefreshing(true); + const result = await probeSocks5(addrInput); + if (!cancelled) { + setProbeStatus(result); + setIsRefreshing(false); + } + }, 400); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [addrInput, networkProxyMode, parsed.phase]); + + const handleRefresh = async () => { + if (parsed.phase !== "complete") return; + setIsRefreshing(true); + setProbeStatus(await probeSocks5(addrInput)); + setIsRefreshing(false); + }; + + const addrIsError = parsed.phase === "invalid"; + const addrHelperText = + parsed.phase === "invalid" + ? parsed.error + : parsed.phase === "partialIp" || + parsed.phase === "ipComplete" || + parsed.phase === "awaitingPort" + ? parsed.hint + : ""; return ( - - - - - - - - + <> + + + + + + { + if ( + newMode === NetworkProxyMode.InternalTor || + newMode === NetworkProxyMode.TorSocks || + newMode === NetworkProxyMode.None + ) { + dispatch(setNetworkProxyMode(newMode)); + } + }} + exclusive + size="small" + > + + + Internal Tor (Recommended) + + + + + Tor Socks (Advanced) + + + + + None + + + + + + + {networkProxyMode === NetworkProxyMode.InternalTor && ( + + + + + + dispatch(setEnableMoneroTor(e.target.checked))} + color="primary" + /> + + + )} + + {networkProxyMode === NetworkProxyMode.TorSocks && ( + + + + + + + + handleAddrChange(e.target.value)} + placeholder="127.0.0.1:9050" + error={addrIsError} + helperText={addrHelperText} + variant="outlined" + size="small" + fullWidth + /> + + + + + + + + {isRefreshing ? : } + + + + {parsed.phase === "complete" && + !isSocksAddressLocalhost(addrInput) && ( + + SOCKS5 traffic between this app and the proxy is + unencrypted. Make sure the network path to{" "} + + {addrInput} + {" "} + is trusted (e.g. an isolated VM network or a secured LAN). + + )} + + + + )} + + + + + + + + dispatch(setAllowDfxClearnet(e.target.checked)) + } + color="primary" + /> + + + ); } diff --git a/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx b/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx index a0178c4910..ba0426fef6 100644 --- a/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx @@ -14,6 +14,7 @@ import { useState } from "react"; import { dfxAuthenticate } from "renderer/rpc"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { isContextWithMoneroWallet } from "models/tauriModelExt"; +import { useAppSelector } from "store/hooks"; function DFXLogo({ height = 24 }: { height?: number }) { return ( @@ -40,6 +41,15 @@ function DFXLogo({ height = 24 }: { height?: number }) { export default function DfxButton() { const [dfxUrl, setDfxUrl] = useState(null); + // DFX only speaks clearnet. Hide the entry point entirely when the user + // has turned the integration off in Settings. + const allowDfxClearnet = useAppSelector( + (state) => state.settings.allowDfxClearnet, + ); + if (!allowDfxClearnet) { + return null; + } + const handleCloseModal = () => { setDfxUrl(null); }; diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index ec819a4c60..2b9074103a 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -19,6 +19,7 @@ import { CheckSeedArgs, CheckSeedResponse, CheckMoneroNodeResponse, + NetworkProxy, TauriSettings, CheckElectrumNodeArgs, CheckElectrumNodeResponse, @@ -84,6 +85,7 @@ import { setStatus } from "store/features/nodesSlice"; import { CliLog } from "models/cliModel"; import { logsToRawString, parseLogsFromString } from "utils/parseUtils"; import { DEFAULT_RENDEZVOUS_POINTS } from "store/defaults"; +import { NetworkProxyMode } from "store/features/settingsSlice"; /// These are the official donation address for the eigenwallet/core project const DONATION_ADDRESS_MAINNET = @@ -219,7 +221,8 @@ export async function buyXmr() { export async function initializeContext() { const network = getNetwork(); const testnet = isTestnet(); - const useTor = store.getState().settings.enableTor; + const networkProxyMode = store.getState().settings.networkProxyMode; + const torSocksAddress = store.getState().settings.torSocksAddress; // Get all Bitcoin nodes without checking availability // The backend ElectrumBalancer will handle load balancing and failover @@ -230,6 +233,7 @@ export async function initializeContext() { const useMoneroRpcPool = store.getState().settings.useMoneroRpcPool; const useMoneroTor = store.getState().settings.enableMoneroTor; + const allowDfxClearnet = store.getState().settings.allowDfxClearnet; const rendezvousPoints = Array.from( new Set([ ...store.getState().settings.rendezvousPoints, @@ -253,13 +257,33 @@ export async function initializeContext() { }, }; + if ( + networkProxyMode === NetworkProxyMode.TorSocks && + (torSocksAddress === null || torSocksAddress === "") + ) { + throw new Error( + "Tor Socks proxy is selected but no address is configured. Enter an IPv4 address (e.g. 127.0.0.1:9050) in Settings.", + ); + } + + const networkProxy: NetworkProxy = + networkProxyMode === NetworkProxyMode.InternalTor + ? { type: "InternalTor" } + : networkProxyMode === NetworkProxyMode.TorSocks + ? { + type: "SystemTorSocks5", + content: { address: torSocksAddress! }, + } + : { type: "None" }; + // Initialize Tauri settings const tauriSettings: TauriSettings = { electrum_rpc_urls: bitcoinNodes, monero_node_config: moneroNodeConfig, - use_tor: useTor, + network_proxy: networkProxy, enable_monero_tor: useMoneroTor, rendezvous_points: rendezvousPoints, + allow_dfx_clearnet: allowDfxClearnet, }; logger.info({ tauriSettings }, "Initializing context with settings"); diff --git a/src-gui/src/renderer/store/storeRenderer.ts b/src-gui/src/renderer/store/storeRenderer.ts index dce4813808..339c7b7d23 100644 --- a/src-gui/src/renderer/store/storeRenderer.ts +++ b/src-gui/src/renderer/store/storeRenderer.ts @@ -3,9 +3,10 @@ import { configureStore, StoreEnhancer, } from "@reduxjs/toolkit"; -import { persistReducer, persistStore } from "redux-persist"; +import { createMigrate, persistReducer, persistStore } from "redux-persist"; import sessionStorage from "redux-persist/lib/storage/session"; import { reducers } from "store/combinedReducer"; +import { NetworkProxyMode } from "store/features/settingsSlice"; import { createMainListeners } from "store/middleware/storeListener"; import { LazyStore } from "@tauri-apps/plugin-store"; @@ -46,10 +47,26 @@ const createTauriStorage = () => ({ }, }); +// Migrate persisted settings across breaking shape changes. +// v2: replace boolean `enableTor` with `networkProxyMode` + `torSocksAddress`. +const settingsMigrations = { + 2: (state: any) => { + if (state && typeof state.enableTor === "boolean") { + state.networkProxyMode = state.enableTor + ? NetworkProxyMode.InternalTor + : NetworkProxyMode.None; + delete state.enableTor; + } + return state; + }, +}; + // Configure how settings are stored and retrieved using Tauri's storage const settingsPersistConfig = { key: "settings", storage: createTauriStorage(), + version: 2, + migrate: createMigrate(settingsMigrations as any, { debug: false }), }; // Persist conversations across application restarts diff --git a/src-gui/src/store/features/settingsSlice.ts b/src-gui/src/store/features/settingsSlice.ts index 54e167840d..96b160d229 100644 --- a/src-gui/src/store/features/settingsSlice.ts +++ b/src-gui/src/store/features/settingsSlice.ts @@ -23,10 +23,16 @@ export interface SettingsState { /// Whether to fetch fiat prices from the internet fetchFiatPrices: boolean; fiatCurrency: FiatCurrency; - /// Whether to enable Tor for p2p connections - enableTor: boolean; + /// Which network proxy mode to use + networkProxyMode: NetworkProxyMode; + /// Address (host:port) for the system Tor SOCKS5 proxy when networkProxyMode is TorSocks + torSocksAddress: string | null; /// Whether to route Monero wallet traffic through Tor enableMoneroTor: boolean; + /// Whether the DFX (fiat on-ramp) integration is enabled. DFX only speaks + /// clearnet, so when this is false the Buy Monero entry-point is hidden + /// and the app never contacts DFX — independent of proxy mode. + allowDfxClearnet: boolean; /// Whether to use the Monero RPC pool for load balancing (true) or custom nodes (false) useMoneroRpcPool: boolean; userHasSeenIntroduction: boolean; @@ -62,6 +68,12 @@ export enum RefundPolicy { External = "external", } +export enum NetworkProxyMode { + InternalTor = "internal_tor", + TorSocks = "tor_socks", + None = "none", +} + export enum FiatCurrency { Usd = "USD", Eur = "EUR", @@ -117,8 +129,10 @@ const initialState: SettingsState = { theme: Theme.Dark, fetchFiatPrices: false, fiatCurrency: FiatCurrency.Usd, - enableTor: true, - enableMoneroTor: false, // Default to not routing Monero traffic through Tor + networkProxyMode: NetworkProxyMode.InternalTor, + torSocksAddress: null, + enableMoneroTor: false, + allowDfxClearnet: true, useMoneroRpcPool: true, // Default to using RPC pool userHasSeenIntroduction: false, userHasSeenAntiSpamInfo: false, @@ -219,12 +233,18 @@ const alertsSlice = createSlice({ resetSettings(_) { return initialState; }, - setTorEnabled(slice, action: PayloadAction) { - slice.enableTor = action.payload; + setNetworkProxyMode(slice, action: PayloadAction) { + slice.networkProxyMode = action.payload; + }, + setTorSocksAddress(slice, action: PayloadAction) { + slice.torSocksAddress = action.payload; }, setEnableMoneroTor(slice, action: PayloadAction) { slice.enableMoneroTor = action.payload; }, + setAllowDfxClearnet(slice, action: PayloadAction) { + slice.allowDfxClearnet = action.payload; + }, setUseMoneroRpcPool(slice, action: PayloadAction) { slice.useMoneroRpcPool = action.payload; }, @@ -337,8 +357,10 @@ export const { resetSettings, setFetchFiatPrices, setFiatCurrency, - setTorEnabled, + setNetworkProxyMode, + setTorSocksAddress, setEnableMoneroTor, + setAllowDfxClearnet, setUseMoneroRpcPool, setUserHasSeenIntroduction, setUserHasSeenAntiSpamInfo, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f81c7e2c3..0159a421ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,17 +11,27 @@ crate-type = ["rlib", "staticlib", "cdylib"] [build-dependencies] tauri-build = { version = "2.*", features = ["config-json5"] } +toml = { workspace = true } [dependencies] swap = { path = "../swap", features = [ "tauri" ] } swap-p2p = { path = "../swap-p2p" } +tor-socks5 = { workspace = true } dfx-swiss-sdk = { workspace = true } +anyhow = { workspace = true } rustls = { version = "0.23.26", default-features = false, features = ["ring"] } +reqwest = { workspace = true, features = ["rustls-tls-native-roots", "socks"] } +# Phantom dep: enable `socks` feature on reqwest 0.13 via Cargo feature unification +# so that tauri-plugin-updater can route through socks5h:// proxies. +# Without this, reqwest 0.13 rejects SOCKS URLs with "unknown proxy scheme". +# build.rs verifies this version still matches what tauri-plugin-updater uses. +reqwest13 = { package = "reqwest", version = "0.13", default-features = false, features = ["socks"] } serde = { version = "1", features = [ "derive" ] } serde_json = "1" tracing = "0.1" +url = { workspace = true } zip = "4.0.0" # Tauri diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 6833e8caa9..c59d0a67e6 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,126 @@ +use std::path::Path; + +/// Verify that the updater still uses a reqwest build with `socks` enabled. +/// +/// `reqwest13` is a compatibility dependency for `tauri-plugin-updater`. +/// Update this check if the updater moves to another reqwest version. +fn reqwest_dep_version(dep: &toml::Value) -> Option<&str> { + dep.as_table()?.get("version")?.as_str() +} + +fn reqwest_dep_package(dep: &toml::Value) -> Option<&str> { + dep.as_table()?.get("package")?.as_str() +} + +fn reqwest_dep_has_feature(dep: &toml::Value, feature: &str) -> bool { + dep.as_table() + .and_then(|table| table.get("features")) + .and_then(toml::Value::as_array) + .is_some_and(|features| features.iter().any(|value| value.as_str() == Some(feature))) +} + +fn verify_reqwest13_manifest(manifest_path: &Path) { + let manifest = + std::fs::read_to_string(manifest_path).expect("cannot read src-tauri/Cargo.toml"); + let manifest: toml::Value = + toml::from_str(&manifest).expect("src-tauri/Cargo.toml must be valid TOML"); + + let reqwest13 = manifest + .get("dependencies") + .and_then(|dependencies| dependencies.get("reqwest13")) + .expect("`reqwest13` dependency missing from src-tauri/Cargo.toml"); + + let Some(package) = reqwest_dep_package(reqwest13) else { + panic!("`reqwest13` must stay an explicit dependency table with `package = \"reqwest\"`"); + }; + if package != "reqwest" { + panic!("`reqwest13` must continue aliasing the `reqwest` crate"); + } + + let Some(version) = reqwest_dep_version(reqwest13) else { + panic!("`reqwest13` must declare an explicit reqwest version"); + }; + if !version.starts_with("0.13") { + panic!("`reqwest13` must continue targeting reqwest 0.13.x until updater changes"); + } + + if !reqwest_dep_has_feature(reqwest13, "socks") { + panic!( + "`reqwest13` must keep `features = [\"socks\"]` or updater SOCKS5 support regresses" + ); + } +} + +fn verify_updater_reqwest_in_lock(lock_path: &Path) { + let lock = std::fs::read_to_string(lock_path).expect("cannot read Cargo.lock"); + let lock: toml::Value = toml::from_str(&lock).expect("Cargo.lock must be valid TOML"); + + let updater = lock + .get("package") + .and_then(toml::Value::as_array) + .and_then(|packages| { + packages.iter().find(|package| { + package + .get("name") + .and_then(toml::Value::as_str) + .is_some_and(|name| name == "tauri-plugin-updater") + }) + }) + .expect("tauri-plugin-updater not found in Cargo.lock"); + + let dependencies = updater + .get("dependencies") + .and_then(toml::Value::as_array) + .expect("tauri-plugin-updater has no dependencies in Cargo.lock — SOCKS5 proxy support needs review"); + + for dependency in dependencies { + let Some(dependency) = dependency.as_str() else { + continue; + }; + let mut parts = dependency.split_whitespace(); + if parts.next() != Some("reqwest") { + continue; + } + + let Some(version) = parts.next() else { + panic!("tauri-plugin-updater reqwest dependency in Cargo.lock has unexpected format"); + }; + if !version.starts_with("0.13.") { + panic!( + "\n\n\ + !! SOCKS5 proxy regression !!\n\ + tauri-plugin-updater now uses reqwest {version}, but the\n\ + `reqwest13` phantom dependency in src-tauri/Cargo.toml targets 0.13.\n\ + Update the phantom dependency version to match, then verify\n\ + that the new reqwest still has a `socks` feature.\n\n" + ); + } + return; + } + + panic!( + "tauri-plugin-updater no longer depends on reqwest — \ + SOCKS5 proxy support needs review" + ); +} + +fn verify_updater_reqwest_socks() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let manifest_path = Path::new(&manifest_dir).join("Cargo.toml"); + let lock_path = Path::new(&manifest_dir) + .parent() + .expect("workspace root") + .join("Cargo.lock"); + + println!("cargo:rerun-if-changed={}", manifest_path.display()); + println!("cargo:rerun-if-changed={}", lock_path.display()); + + verify_reqwest13_manifest(&manifest_path); + verify_updater_reqwest_in_lock(&lock_path); +} + fn main() { + verify_updater_reqwest_socks(); #[cfg(target_os = "windows")] { #[cfg(not(host_os = "linux"))] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e3f5c5b9cc..817b43aa1a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; use std::io::Write; use std::result::Result; +use std::time::Duration; use swap::cli::{ api::{ - ContextBuilder, data, + ContextBuilder, NetworkProxyConfig, data, request::{ BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs, CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, @@ -19,7 +20,7 @@ use swap::cli::{ SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, - tauri_bindings::{ContextStatus, TauriSettings}, + tauri_bindings::{ContextStatus, NetworkProxy, TauriSettings}, }, command::Bitcoin, }; @@ -79,7 +80,11 @@ macro_rules! generate_command_handlers { get_monero_subaddresses, create_monero_subaddress, set_monero_subaddress_label, - refresh_p2p + refresh_p2p, + http_get, + http_post_json, + check_socks5_address, + get_updater_proxy_url ] }; } @@ -153,6 +158,89 @@ mod util { } } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpGetArgs { + pub url: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpPostJsonArgs { + pub url: String, + pub body: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +#[tauri::command] +pub async fn http_get(args: HttpGetArgs) -> Result { + send_http_request(reqwest::Method::GET, args.url, None).await +} + +#[tauri::command] +pub async fn http_post_json(args: HttpPostJsonArgs) -> Result { + send_http_request(reqwest::Method::POST, args.url, Some(args.body)).await +} + +#[tauri::command] +pub async fn check_socks5_address(address: String) -> bool { + tokio::task::spawn_blocking(move || tor_socks5::probe_addr_str(&address)) + .await + .unwrap_or(false) +} + +/// Build the updater SOCKS5 URL from a persisted IPv4 address. +#[tauri::command] +pub fn get_updater_proxy_url(address: String) -> Result { + let addr: std::net::SocketAddrV4 = address.parse().map_err(|e| { + format!("Invalid SOCKS5 proxy address '{address}': {e}. Expected IPv4 ip:port, e.g. 127.0.0.1:9050.") + })?; + Ok(tor_socks5::Subsystem::Updater.proxy_url_for(addr)) +} + +async fn send_http_request( + method: reqwest::Method, + url: String, + body: Option, +) -> Result { + let parsed_url = + reqwest::Url::parse(&url).map_err(|e| format!("Failed to parse URL '{url}': {e}"))?; + let client = crate::http_client::build_http_client(&parsed_url, Duration::from_secs(20)) + .map_err(|e| format!("Failed to build HTTP client for '{url}': {e:#}"))?; + + let mut request = client.request(method.clone(), parsed_url.clone()); + + if let Some(body) = body { + request = request + .header("Content-Type", "application/json") + .body(body); + } + + let response = request.send().await.map_err(|e| { + format!( + "Failed to send {} request to '{}': {e:#}", + method.as_str(), + parsed_url + ) + })?; + let status = response.status().as_u16(); + let body = response.text().await.map_err(|e| { + format!( + "Failed to read {} response body from '{}': {e:#}", + method.as_str(), + parsed_url + ) + })?; + + Ok(HttpResponse { status, body }) +} + /// Tauri command to initialize the Context #[tauri::command] pub async fn initialize_context( @@ -178,6 +266,22 @@ pub async fn initialize_context( // Parse rendeuvous points let rendezvous_points = settings.rendezvous_points.extract_peer_addresses(); + let network_proxy = match settings.network_proxy { + NetworkProxy::InternalTor => NetworkProxyConfig::InternalTor, + NetworkProxy::None => NetworkProxyConfig::None, + NetworkProxy::SystemTorSocks5 { address } => { + let addr: std::net::SocketAddrV4 = address.parse().map_err(|e| { + format!("Invalid SOCKS5 proxy address '{address}': {e}. Expected IPv4 ip:port, e.g. 127.0.0.1:9050.") + })?; + NetworkProxyConfig::SystemTorSocks5(addr) + } + }; + + // Store the DFX kill switch from persisted settings. + state + .allow_dfx_clearnet + .store(settings.allow_dfx_clearnet, std::sync::atomic::Ordering::Relaxed); + // Now populate the context in the background let context_result = ContextBuilder::new(testnet) .with_bitcoin(Bitcoin { @@ -186,7 +290,7 @@ pub async fn initialize_context( }) .with_monero(settings.monero_node_config) .with_json(false) - .with_tor(settings.use_tor) + .with_network_proxy(network_proxy) .with_enable_monero_tor(settings.enable_monero_tor) .with_rendezvous_points(rendezvous_points) .with_tauri(tauri_handle.clone()) @@ -393,10 +497,22 @@ pub async fn save_txt_files( pub async fn dfx_authenticate( state: tauri::State<'_, State>, ) -> Result { + const DFX_API_BASE_URL: &str = "https://api.dfx.swiss"; use dfx_swiss_sdk::{DfxClient, SignRequest}; use tokio::sync::{mpsc, oneshot}; use tokio_util::task::AbortOnDropHandle; + // DFX is only available when the user enables the clearnet path. + if !state + .allow_dfx_clearnet + .load(std::sync::atomic::Ordering::Relaxed) + { + return Err( + "DFX integration is disabled. Enable 'DFX (clearnet only)' in Settings to use it." + .to_string(), + ); + } + let context = state.context(); // Get the monero wallet manager @@ -415,8 +531,8 @@ pub async fn dfx_authenticate( // Create channel for authentication let (auth_tx, mut auth_rx) = mpsc::channel::<(SignRequest, oneshot::Sender)>(10); - // Create DFX client - let mut client = DfxClient::new(address, Some("https://api.dfx.swiss".to_string()), auth_tx); + // Keep DFX on its direct HTTP path. + let mut client = DfxClient::new(address, Some(DFX_API_BASE_URL.to_string()), auth_tx); // Start signing task with AbortOnDropHandle let signing_task = tokio::spawn(async move { @@ -518,3 +634,32 @@ tauri_command!(create_monero_subaddress, CreateMoneroSubaddressArgs); tauri_command!(set_monero_subaddress_label, SetMoneroSubaddressLabelArgs); tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args); tauri_command!(refresh_p2p, RefreshP2PArgs, no_args); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_updater_proxy_url_accepts_valid_ipv4() { + // Keep the updater URL format aligned with `ProxyConfig::url()`. + let url = get_updater_proxy_url("127.0.0.1:9050".to_string()).unwrap(); + assert_eq!(url, "socks5h://updater:updater@127.0.0.1:9050"); + } + + #[test] + fn get_updater_proxy_url_rejects_invalid_address() { + // Reject non-IPv4 `ip:port` inputs. + assert!(get_updater_proxy_url("localhost:9050".to_string()).is_err()); + assert!(get_updater_proxy_url("[::1]:9050".to_string()).is_err()); + assert!(get_updater_proxy_url("127.0.0.1".to_string()).is_err()); + assert!(get_updater_proxy_url("".to_string()).is_err()); + } + + #[tokio::test] + async fn check_socks5_address_returns_false_for_invalid_input() { + // Invalid input should map to `false`. + assert!(!check_socks5_address("not-an-addr".to_string()).await); + assert!(!check_socks5_address("".to_string()).await); + assert!(!check_socks5_address("localhost:9050".to_string()).await); + } +} diff --git a/src-tauri/src/http_client.rs b/src-tauri/src/http_client.rs new file mode 100644 index 0000000000..45af8ebecf --- /dev/null +++ b/src-tauri/src/http_client.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use std::time::Duration; +use tor_socks5::Subsystem; + +/// Set on every GUI-originated HTTP request. CoinGecko's fiat price API +/// (see `fetchCurrencyPrice` in `src-gui/src/renderer/api.ts`) refuses +/// requests without a User-Agent, so we always announce ourselves. +const GUI_HTTP_USER_AGENT: &str = concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION"), + " (+https://github.com/eigenwallet/core)" +); + +pub fn build_http_client(url: &reqwest::Url, timeout: Duration) -> Result { + swap::common::http::build_http_client(url, timeout, Subsystem::Http, Some(GUI_HTTP_USER_AGENT)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6331891eaa..ffade155fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,13 @@ +use reqwest13 as _; // phantom dep — enables socks feature on reqwest 0.13 for tauri-plugin-updater use std::result::Result; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use swap::cli::api::{Context, tauri_bindings::TauriHandle}; use tauri::{Manager, RunEvent}; use tokio::sync::Mutex; mod commands; +mod http_client; use commands::*; @@ -19,6 +22,11 @@ struct State { /// However, we want to avoid multiple processes intializing the context at the same time. pub context_lock: Mutex<()>, pub handle: TauriHandle, + /// Whether the DFX (fiat on-ramp) integration is enabled. DFX only speaks + /// clearnet, so this flag acts as a user-controlled kill switch, + /// independent of proxy mode. Mirrors the value the frontend sends via + /// `initialize_context`. `dfx_authenticate` bails when this is false. + pub allow_dfx_clearnet: AtomicBool, } impl State { @@ -31,6 +39,7 @@ impl State { context, context_lock, handle, + allow_dfx_clearnet: AtomicBool::new(true), } } diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index b19920fa33..7b6c45c2af 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -1261,9 +1261,6 @@ pub enum MoneroNodeConfig { } /// How the app should route its network traffic. -/// -/// Makes invalid combinations of the previous `use_tor` / `use_system_tor_socks5` -/// / `tor_socks5_address` triple unrepresentable. #[typeshare] #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type", content = "content")] @@ -1291,10 +1288,7 @@ pub struct TauriSettings { pub enable_monero_tor: bool, /// The list of rendezvous points to connect to pub rendezvous_points: Vec, - /// Whether the DFX (fiat on-ramp) integration is enabled. DFX only speaks - /// clearnet, so this flag gates the integration entirely — independent of - /// proxy mode. When `false`, the backend refuses DFX requests and the UI - /// hides the Buy Monero entry-point. + /// Whether the DFX integration is enabled. #[serde(default = "default_allow_dfx_clearnet")] pub allow_dfx_clearnet: bool, } @@ -1376,9 +1370,7 @@ mod tests { #[test] fn network_proxy_serde_round_trip() { - // The frontend relies on the exact `{ "type": "...", "content": ... }` - // shape (typeshare-generated). Any drift in the serde attributes or - // variant names breaks the rpc.ts handshake silently. + // Keep the JSON shape stable for the renderer RPC contract. let cases = vec![ NetworkProxy::InternalTor, NetworkProxy::None, @@ -1391,8 +1383,7 @@ mod tests { let json = serde_json::to_value(&original).expect("serialize NetworkProxy"); let round_tripped: NetworkProxy = serde_json::from_value(json.clone()).expect("deserialize NetworkProxy"); - // NetworkProxy is not PartialEq; compare through the JSON surface, - // which is exactly what the frontend contract cares about. + // NetworkProxy is not PartialEq; compare the serialized form. let round_tripped_json = serde_json::to_value(&round_tripped).expect("re-serialize NetworkProxy"); assert_eq!(json, round_tripped_json); @@ -1401,7 +1392,7 @@ mod tests { #[test] fn network_proxy_tag_and_content_shape() { - // Matches the exact JSON the GUI constructs in rpc.ts. + // Expected JSON encoding for `NetworkProxy`. let internal = serde_json::to_value(NetworkProxy::InternalTor).unwrap(); assert_eq!(internal, serde_json::json!({ "type": "InternalTor" })); @@ -1423,9 +1414,7 @@ mod tests { #[test] fn tauri_settings_defaults_allow_dfx_clearnet_when_missing() { - // Old GUI builds won't send `allow_dfx_clearnet`; serde default must - // preserve the previous clearnet-enabled behaviour so upgrades don't - // silently disable the Buy Monero entry-point. + // Preserve the previous default when older GUI builds omit the field. let json = serde_json::json!({ "monero_node_config": { "type": "Pool" }, "electrum_rpc_urls": [], From f92eec046ae414d039f0a747b2c1d1272f2df3e7 Mon Sep 17 00:00:00 2001 From: fleebicorn <278484091+fleebicorn@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:47:58 +0000 Subject: [PATCH 3/3] changelog: note tor socks5 proxy support --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d2ad545c..c044fd5662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- GUI: Add a network proxy mode in the settings UI so the application can be routed through a user-configured system Tor SOCKS5 proxy. +- GUI + SWAP + ASB: Route the relevant HTTP, wallet, updater, and transport paths through shared system Tor SOCKS5 plumbing instead of each subsystem carrying its own proxy logic. + ## [4.5.0] - 2026-04-27 - ASB+CONTROLLER: `get-swaps` now includes the `btc_redeem_fee` per swap: the fee Alice paid (or will pay) for the Bitcoin redeem transaction.