From f6a183f73963fce7aa6ece46153689a1eadea81e Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Fri, 18 Sep 2026 08:30:33 +0100 Subject: [PATCH] fix(client): route electrum & esplora traffic through config proxy `--proxy` accepted proxy_opts for both electrum and esplora clients but did not use them during connection. This fix updates both clients to use the provided proxy options during connection - added test for connection through proxy - updated CHANGELOG --- CHANGELOG.md | 2 + src/client.rs | 86 ++++++++++++++- tests/cli.rs | 1 + tests/integration/proxy.rs | 209 +++++++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 tests/integration/proxy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d127b1..7f4cd38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. - Fixed the data directory and config.toml permission being world-readable (0755/0644) to 0700/0600 on Unix. - Fixed `create_tx` and `bump_fee` panicking on malformed `--utxos` and `--add_data` values instead of returning an error - Fixed `--fee_rate` silently truncating to a whole sat/vB, falling back to a default, or producing a zero-fee transaction, unusable values are now rejected +- Fixed routing electrum and esplora traffic through configured socks5 proxy +- Rejected `--proxy` on the `rpc` and `cbf` backends ## [4.0.0] diff --git a/src/client.rs b/src/client.rs index 54c28d5..6d69898 100644 --- a/src/client.rs +++ b/src/client.rs @@ -22,6 +22,10 @@ use { bdk_wallet::chain::CanonicalizationParams, }; +#[cfg(any(feature = "electrum", feature = "esplora"))] +use crate::commands::ProxyOpts; +#[cfg(feature = "electrum")] +use std::time::Duration; #[cfg(feature = "cbf")] use {crate::utils::trace_logger, bdk_kyoto::BuilderExt}; @@ -202,6 +206,71 @@ pub struct KyotoClientHandle { tokio::sync::Mutex>, } +/// Build the electrum [`Config`] from the wallet's SOCKS5 proxy options. +/// +/// The hostname is handed to the proxy as a `TargetAddr::Domain`, so the target is +/// resolved by the proxy rather than locally, and no DNS query leaks. +#[cfg(feature = "electrum")] +fn electrum_config(proxy_opts: &ProxyOpts) -> bdk_electrum::electrum_client::Config { + use bdk_electrum::electrum_client::{ConfigBuilder, Socks5Config}; + + let socks5 = proxy_opts + .proxy + .as_ref() + .map(|addr| match &proxy_opts.proxy_auth { + Some((user, password)) => { + Socks5Config::with_credentials(addr, user.clone(), password.clone()) + } + None => Socks5Config::new(addr), + }); + + ConfigBuilder::new() + .socks5(socks5) + .retry(proxy_opts.retries) + .timeout( + proxy_opts + .timeout + .map(|secs| Duration::from_secs(secs as u64)), + ) + .build() +} + +/// Render the SOCKS5 proxy options as a URL for esplora's HTTP client. +/// +/// `socks5h` rather than `socks5` so the proxy resolves the esplora hostname; with +/// plain `socks5` the client resolves it locally first, leaking a DNS query that +/// identifies the server being synced against. +#[cfg(feature = "esplora")] +fn esplora_proxy_url(proxy_opts: &ProxyOpts) -> Option { + let addr = proxy_opts.proxy.as_ref()?; + let addr = addr + .strip_prefix("socks5h://") + .or_else(|| addr.strip_prefix("socks5://")) + .unwrap_or(addr); + + Some(match &proxy_opts.proxy_auth { + Some((user, password)) => format!("socks5h://{user}:{password}@{addr}"), + None => format!("socks5h://{addr}"), + }) +} + +/// Reject a proxy the backend client does not understand. +/// +/// `bitcoind`'s RPC client and the compact block filter backend have no SOCKS5 +/// support here, so a proxy set against them would silently do nothing. +#[cfg(all( + any(feature = "electrum", feature = "esplora"), + any(feature = "rpc", feature = "cbf") +))] +fn reject_unsupported_proxy(proxy_opts: &ProxyOpts, backend: &str) -> Result<(), Error> { + match proxy_opts.proxy { + Some(_) => Err(Error::Generic(format!( + "The {backend} backend does not support a SOCKS5 proxy. Remove --proxy, or use the electrum or esplora backend." + ))), + None => Ok(()), + } +} + #[cfg(any( feature = "electrum", feature = "esplora", @@ -219,7 +288,8 @@ pub(crate) fn new_blockchain_client( let client = match wallet_opts.client_type { #[cfg(feature = "electrum")] ClientType::Electrum => { - let client = bdk_electrum::electrum_client::Client::new(url) + let config = electrum_config(&wallet_opts.proxy_opts); + let client = bdk_electrum::electrum_client::Client::from_config(url, config) .map(bdk_electrum::BdkElectrumClient::new)?; BlockchainClient::Electrum { client: Box::new(client), @@ -228,7 +298,15 @@ pub(crate) fn new_blockchain_client( } #[cfg(feature = "esplora")] ClientType::Esplora => { - let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; + let mut builder = bdk_esplora::esplora_client::Builder::new(url) + .max_retries(wallet_opts.proxy_opts.retries as usize); + if let Some(proxy) = esplora_proxy_url(&wallet_opts.proxy_opts) { + builder = builder.proxy(&proxy); + } + if let Some(timeout) = wallet_opts.proxy_opts.timeout { + builder = builder.timeout(timeout as u64); + } + let client = builder.build_async()?; BlockchainClient::Esplora { client: Box::new(client), parallel_requests: wallet_opts.parallel_requests, @@ -237,6 +315,8 @@ pub(crate) fn new_blockchain_client( #[cfg(feature = "rpc")] ClientType::Rpc => { + #[cfg(any(feature = "electrum", feature = "esplora"))] + reject_unsupported_proxy(&wallet_opts.proxy_opts, "rpc")?; let auth = match &wallet_opts.cookie { Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( @@ -253,6 +333,8 @@ pub(crate) fn new_blockchain_client( #[cfg(feature = "cbf")] ClientType::Cbf => { + #[cfg(any(feature = "electrum", feature = "esplora"))] + reject_unsupported_proxy(&wallet_opts.proxy_opts, "cbf")?; let scan_type = bdk_kyoto::ScanType::Sync; let builder = bdk_kyoto::builder::Builder::new(_wallet.network()); diff --git a/tests/cli.rs b/tests/cli.rs index 9285327..c475904 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -17,4 +17,5 @@ mod integration { mod init; mod offline; mod online; + mod proxy; } diff --git a/tests/integration/proxy.rs b/tests/integration/proxy.rs new file mode 100644 index 0000000..264cc27 --- /dev/null +++ b/tests/integration/proxy.rs @@ -0,0 +1,209 @@ +//! The SOCKS5 proxy options must actually reach the blockchain client. +//! +//! These tests stand two TCP listeners in for the chain server and the proxy, so +//! they need neither a real node nor a real SOCKS5 service: all that matters is +//! which port the connection arrives on. +#[cfg(any(feature = "electrum", feature = "esplora"))] +mod test_proxy { + use crate::common::BdkCli; + use assert_cmd::Command; + #[cfg(feature = "rpc")] + use predicates::prelude::*; + use serde_json::Value; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc::{Receiver, channel}; + use std::thread; + use std::time::Duration; + use tempfile::TempDir; + + static WALLET_NAME: &str = "proxy_test_wallet"; + + fn spawn_listener() -> (String, Receiver<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind listener"); + let addr = listener.local_addr().unwrap().to_string(); + let (tx, rx) = channel(); + + thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + drop::(stream); + if tx.send(()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + (addr, rx) + } + + fn connected(rx: &Receiver<()>) -> bool { + rx.recv_timeout(Duration::from_secs(5)).is_ok() + } + + /// As above, but for an arbitrary client type and url. + fn setup_wallet_for( + client_type: &str, + url: &str, + proxy_addr: Option<&str>, + ) -> (BdkCli, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let desc = cli + .cmd("descriptor", &["--type", "wpkh"]) + .output() + .expect("failed to generate descriptors"); + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("invalid JSON from descriptor"); + let public = &desc_values["public_descriptors"]; + let ext_desc = public["external"].as_str().unwrap(); + let int_desc = public["internal"].as_str().unwrap(); + + let mut cmd = cli.build_base_cmd(); + cmd.arg("wallet") + .arg("--wallet") + .arg(WALLET_NAME) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg(client_type) + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg(url); + if let Some(proxy) = proxy_addr { + cmd.arg("--proxy").arg(proxy); + } + cmd.assert().success(); + + (cli, temp_dir) + } + + /// Runs `sync` and returns the command, so callers can assert on how it failed. + fn sync_cmd(cli: &BdkCli) -> Command { + let mut cmd = cli.wallet_cmd(&["--wallet", WALLET_NAME, "sync"]); + cmd.timeout(Duration::from_secs(30)); + cmd + } + + /// With `--proxy` set, the connection must go to the proxy and never to the + /// chain server directly. + #[cfg(feature = "electrum")] + #[test] + fn test_electrum_sync_goes_through_the_proxy() { + let (server_addr, server_rx) = spawn_listener(); + let (proxy_addr, proxy_rx) = spawn_listener(); + + let (cli, _temp_dir) = setup_wallet_for( + "electrum", + &format!("tcp://{server_addr}"), + Some(&proxy_addr), + ); + + // The stub proxy does not speak SOCKS5, so the sync must fail rather than + // quietly falling back to a direct connection. + sync_cmd(&cli).assert().failure(); + + assert!( + connected(&proxy_rx), + "the proxy was never contacted: traffic bypassed --proxy" + ); + assert!( + !connected(&server_rx), + "a direct connection reached the chain server despite --proxy" + ); + } + + /// Without `--proxy`, the connection goes straight to the chain server. This is + /// the control: it shows the test above is detecting the proxy, not a failure + /// to connect at all. + #[cfg(feature = "electrum")] + #[test] + fn test_electrum_sync_without_proxy_goes_direct() { + let (server_addr, server_rx) = spawn_listener(); + + let (cli, _temp_dir) = setup_wallet_for("electrum", &format!("tcp://{server_addr}"), None); + + sync_cmd(&cli).assert().failure(); + + assert!( + connected(&server_rx), + "no connection reached the chain server" + ); + } + + /// The esplora backend must honour `--proxy` just as electrum does. + #[cfg(feature = "esplora")] + #[test] + fn test_esplora_sync_goes_through_the_proxy() { + let (server_addr, server_rx) = spawn_listener(); + let (proxy_addr, proxy_rx) = spawn_listener(); + + let (cli, _temp_dir) = setup_wallet_for( + "esplora", + &format!("http://{server_addr}"), + Some(&proxy_addr), + ); + + sync_cmd(&cli).assert().failure(); + + assert!( + connected(&proxy_rx), + "the proxy was never contacted: traffic bypassed --proxy" + ); + assert!( + !connected(&server_rx), + "a direct connection reached the chain server despite --proxy" + ); + } + + /// A proxy the backend cannot honour is rejected, rather than ignored. + #[cfg(feature = "rpc")] + #[test] + fn test_rpc_backend_rejects_a_proxy() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let desc = cli + .cmd("descriptor", &["--type", "wpkh"]) + .output() + .expect("failed to generate descriptors"); + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("invalid JSON from descriptor"); + let public = &desc_values["public_descriptors"]; + + cli.build_base_cmd() + .arg("wallet") + .arg("--wallet") + .arg(WALLET_NAME) + .arg("config") + .arg("--ext-descriptor") + .arg(public["external"].as_str().unwrap()) + .arg("--int-descriptor") + .arg(public["internal"].as_str().unwrap()) + .arg("--client-type") + .arg("rpc") + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg("127.0.0.1:18443") + .arg("--proxy") + .arg("127.0.0.1:9050") + .assert() + .success(); + + sync_cmd(&cli) + .assert() + .failure() + .stderr(predicate::str::contains( + "rpc backend does not support a SOCKS5 proxy", + )); + } +} -- 2.49.0