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. 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/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-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..7b6c45c2af 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -1260,6 +1260,20 @@ pub enum MoneroNodeConfig { SingleNode { url: String }, } +/// How the app should route its network traffic. +#[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 +1282,19 @@ 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 integration is enabled. + #[serde(default = "default_allow_dfx_clearnet")] + pub allow_dfx_clearnet: bool, +} + +fn default_allow_dfx_clearnet() -> bool { + true } #[typeshare] @@ -1342,3 +1363,67 @@ impl Drop for ApprovalCleanupGuard { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn network_proxy_serde_round_trip() { + // Keep the JSON shape stable for the renderer RPC contract. + 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 the serialized form. + 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() { + // Expected JSON encoding for `NetworkProxy`. + 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() { + // Preserve the previous default when older GUI builds omit the field. + 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()); + } +}