From aff3954585b476c2502e9c775b2f9523399bb7c4 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Fri, 18 Sep 2026 08:30:33 +0100 Subject: [PATCH 1/2] 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 5d127b14..7f4cd38f 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 54c28d55..6d69898b 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 9285327c..c4759043 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 00000000..264cc272 --- /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", + )); + } +} From b2317a81be99bf082b2ee9b894ce0058e375ed7f Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Fri, 18 Sep 2026 20:58:01 +0100 Subject: [PATCH 2/2] fix(client): Route cbf traffic via configured SOCKS5 proxy - The cbf arm of new_blockchain_client was ignored proxy_opts but bdk_kyoto 0.17.0 support a proxy. This change adds the connection through socks5 proxy. - add tests coverage for cbf connection through socks5 proxy --- CHANGELOG.md | 4 +- src/client.rs | 29 ++----- src/commands.rs | 150 +++++++++++++++++++++++++++++++++- src/config.rs | 11 ++- src/handlers/config.rs | 2 +- tests/integration/proxy.rs | 162 +++++++++++++++++++++++++++++-------- 6 files changed, 294 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4cd38f..f2bf0503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. - 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 - +- Routed compact filter (cbf) traffic through the configured SOCKS5 proxy +- Rejected `--proxy` on the `rpc` backend, and unsupported proxy options (`--proxy_auth`, `--timeout`) on the `rpc` and `cbf` backends, instead of silently ignoring them ## [4.0.0] diff --git a/src/client.rs b/src/client.rs index 6d69898b..bce66a18 100644 --- a/src/client.rs +++ b/src/client.rs @@ -254,23 +254,6 @@ fn esplora_proxy_url(proxy_opts: &ProxyOpts) -> Option { }) } -/// 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", @@ -315,8 +298,7 @@ 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")?; + wallet_opts.reject_proxy("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( @@ -333,10 +315,13 @@ 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")?; + wallet_opts.reject_proxy_auth("cbf")?; + let scan_type = bdk_kyoto::ScanType::Sync; - let builder = bdk_kyoto::builder::Builder::new(_wallet.network()); + let mut builder = bdk_kyoto::builder::Builder::new(_wallet.network()); + if let Some(proxy) = wallet_opts.proxy_opts.socket_addr()? { + builder = builder.socks5_proxy(proxy); + } let light_client = builder .required_peers(wallet_opts.compactfilter_opts.conn_count) diff --git a/src/commands.rs b/src/commands.rs index 37f80523..86826ffe 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -53,9 +53,10 @@ use bdk_wallet::bitcoin::Network; use clap::{Args, Parser, Subcommand, value_parser}; use clap_complete::Shell; +#[cfg(any(feature = "rpc", feature = "cbf"))] +use crate::error::BDKCliError as Error; #[cfg(feature = "dns_payment")] use crate::handlers::dns::{CreateDnsTxCommand, ResolveDnsRecipientCommand}; - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] use crate::utils::parse_proxy_auth; @@ -284,13 +285,68 @@ pub struct WalletOpts { #[cfg(feature = "cbf")] #[clap(flatten)] pub compactfilter_opts: CompactFilterOpts, - #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] #[command(flatten)] pub proxy_opts: ProxyOpts, } +#[cfg(any(feature = "rpc", feature = "cbf"))] +impl WalletOpts { + /// Reject a proxy the selected backend cannot honour at all, rather than + /// silently ignoring it. + /// + /// `--retries` cannot be checked the same way: it defaults to 5, so a + /// user-supplied value cannot be told apart from the default. + #[cfg(feature = "rpc")] + pub(crate) fn reject_proxy(&self, _backend: &str) -> Result<(), Error> { + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] + if self.proxy_opts.proxy.is_some() { + return Err(Error::Generic(format!( + "The {_backend} backend does not support a SOCKS5 proxy. \ + Remove --proxy, or use the electrum, esplora or cbf backend." + ))); + } + #[cfg(any(feature = "electrum", feature = "esplora"))] + if self.proxy_opts.proxy_auth.is_some() { + return Err(Error::Generic(format!( + "The {_backend} backend does not support --proxy_auth." + ))); + } + #[cfg(any(feature = "electrum", feature = "esplora"))] + if self.proxy_opts.timeout.is_some() { + return Err(Error::Generic(format!( + "The {_backend} backend does not support --timeout." + ))); + } + Ok(()) + } + + /// Reject proxy options the cbf backend cannot honour, even though it does + /// support `--proxy` itself. + /// + /// Kyoto takes the proxy as a bare `SocketAddr`, so it has nowhere to put a + /// username and password, and no proxy-specific timeout knob. `--retries` + /// cannot be checked for the same reason noted on `reject_proxy`. + #[cfg(feature = "cbf")] + pub(crate) fn reject_proxy_auth(&self, _backend: &str) -> Result<(), Error> { + #[cfg(any(feature = "electrum", feature = "esplora"))] + if self.proxy_opts.proxy_auth.is_some() { + return Err(Error::Generic(format!( + "The {_backend} backend does not support --proxy_auth." + ))); + } + #[cfg(any(feature = "electrum", feature = "esplora"))] + if self.proxy_opts.timeout.is_some() { + return Err(Error::Generic(format!( + "The {_backend} backend does not support --timeout." + ))); + } + Ok(()) + } +} + /// Options to configure a SOCKS5 proxy for a blockchain client connection. -#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] #[derive(Debug, Args, Clone, PartialEq, Eq)] pub struct ProxyOpts { /// Sets the SOCKS5 proxy for a blockchain client. @@ -298,10 +354,12 @@ pub struct ProxyOpts { pub proxy: Option, /// Sets the SOCKS5 proxy credential. + #[cfg(any(feature = "electrum", feature = "esplora"))] #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", value_parser = parse_proxy_auth)] pub proxy_auth: Option<(String, String)>, /// Sets the SOCKS5 proxy retries for the blockchain client. + #[cfg(any(feature = "electrum", feature = "esplora"))] #[arg( env = "PROXY_RETRIES", short = 'r', @@ -311,10 +369,32 @@ pub struct ProxyOpts { pub retries: u8, /// Sets the SOCKS5 proxy timeout for the blockchain client. + #[cfg(any(feature = "electrum", feature = "esplora"))] #[arg(env = "PROXY_TIMEOUT", short = 't', long = "timeout")] pub timeout: Option, } +#[cfg(feature = "cbf")] +impl ProxyOpts { + /// The proxy as a [`SocketAddr`] for kyoto. + /// + /// Unlike the electrum and esplora backends this cannot take a hostname. + pub(crate) fn socket_addr(&self) -> Result, Error> { + let Some(addr) = self.proxy.as_ref() else { + return Ok(None); + }; + let addr = addr + .strip_prefix("socks5h://") + .or_else(|| addr.strip_prefix("socks5://")) + .unwrap_or(addr); + + addr.parse().map(Some).map_err(|_| { + Error::Generic(format!( + "The cbf backend needs --proxy as an ip:port address, but got '{addr}'." + )) + }) + } +} /// Options to configure a BIP157 Compact Filter backend. #[cfg(feature = "cbf")] #[derive(Debug, Args, Clone, PartialEq, Eq)] @@ -437,3 +517,67 @@ pub enum ReplSubCommand { /// Exit REPL loop. Exit, } + +#[cfg(all(test, feature = "cbf"))] +mod cbf_proxy_tests { + use super::*; + use std::net::SocketAddr; + + /// `ProxyOpts` carrying only a proxy; the other fields exist for the electrum + /// and esplora backends, which kyoto does not share. + fn proxy_opts(proxy: &str) -> ProxyOpts { + ProxyOpts { + proxy: Some(proxy.to_string()), + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_auth: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + retries: 5, + #[cfg(any(feature = "electrum", feature = "esplora"))] + timeout: None, + } + } + + #[test] + fn parses_the_spellings_the_other_backends_accept() { + let expected = Some(SocketAddr::from(([127, 0, 0, 1], 9050))); + + assert_eq!( + proxy_opts("127.0.0.1:9050").socket_addr().unwrap(), + expected + ); + assert_eq!( + proxy_opts("socks5://127.0.0.1:9050").socket_addr().unwrap(), + expected + ); + assert_eq!( + proxy_opts("socks5h://127.0.0.1:9050") + .socket_addr() + .unwrap(), + expected + ); + } + + #[test] + fn parses_an_ipv6_proxy() { + assert_eq!( + proxy_opts("[::1]:9050").socket_addr().unwrap(), + Some("[::1]:9050".parse::().unwrap()) + ); + } + + #[test] + fn rejects_a_hostname_kyoto_cannot_use() { + let err = proxy_opts("tor.local:9050").socket_addr().unwrap_err(); + assert!( + err.to_string().contains("ip:port"), + "unhelpful error: {err}" + ); + } + + #[test] + fn no_proxy_is_not_an_error() { + let mut opts = proxy_opts("127.0.0.1:9050"); + opts.proxy = None; + assert_eq!(opts.socket_addr().unwrap(), None); + } +} diff --git a/src/config.rs b/src/config.rs index e905c427..5e95dc5e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -51,7 +51,7 @@ pub struct WalletConfigInner { pub parallel_requests: Option, #[cfg(feature = "rpc")] pub cookie: Option, - #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] #[serde(default)] pub proxy: Option, #[cfg(any(feature = "electrum", feature = "esplora"))] @@ -175,14 +175,17 @@ impl TryFrom<&WalletConfigInner> for WalletOpts { #[cfg(feature = "rpc")] cookie: config.cookie.clone(), - #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] proxy_opts: crate::commands::ProxyOpts { proxy: config.proxy.clone(), + #[cfg(any(feature = "electrum", feature = "esplora"))] proxy_auth: match &config.proxy_auth { Some(s) => Some(crate::utils::parse_proxy_auth(s)?), None => None, }, + #[cfg(any(feature = "electrum", feature = "esplora"))] retries: config.proxy_retries.unwrap_or(5), + #[cfg(any(feature = "electrum", feature = "esplora"))] timeout: config.proxy_timeout, }, @@ -251,7 +254,7 @@ mod tests { rpc_password: None, #[cfg(feature = "rpc")] cookie: None, - #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] proxy: None, #[cfg(any(feature = "electrum", feature = "esplora"))] proxy_auth: None, @@ -336,7 +339,7 @@ mod tests { rpc_password: None, #[cfg(feature = "rpc")] cookie: None, - #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] proxy: None, #[cfg(any(feature = "electrum", feature = "esplora"))] proxy_auth: None, diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 13131c19..905011a6 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -128,7 +128,7 @@ impl AppCommand> for SaveConfigCommand { #[cfg(feature = "rpc")] cookie: self.wallet_opts.cookie.clone(), - #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] proxy: self.wallet_opts.proxy_opts.proxy.clone(), #[cfg(any(feature = "electrum", feature = "esplora"))] proxy_auth: self diff --git a/tests/integration/proxy.rs b/tests/integration/proxy.rs index 264cc272..2d676b6f 100644 --- a/tests/integration/proxy.rs +++ b/tests/integration/proxy.rs @@ -3,21 +3,27 @@ //! 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"))] +#[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] mod test_proxy { use crate::common::BdkCli; use assert_cmd::Command; - #[cfg(feature = "rpc")] + #[cfg(any(feature = "rpc", feature = "cbf"))] use predicates::prelude::*; use serde_json::Value; + #[cfg(any(feature = "electrum", feature = "esplora"))] use std::net::{TcpListener, TcpStream}; + #[cfg(any(feature = "electrum", feature = "esplora"))] use std::sync::mpsc::{Receiver, channel}; + #[cfg(any(feature = "electrum", feature = "esplora"))] use std::thread; use std::time::Duration; use tempfile::TempDir; static WALLET_NAME: &str = "proxy_test_wallet"; + #[cfg(any(feature = "electrum", feature = "esplora"))] + /// Accept connections on an ephemeral port, reporting each one and closing it + /// immediately. Returns the bound address and the receiving end of the report. 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(); @@ -40,11 +46,15 @@ mod test_proxy { (addr, rx) } + #[cfg(any(feature = "electrum", feature = "esplora"))] + /// Did a connection arrive within the grace period? fn connected(rx: &Receiver<()>) -> bool { rx.recv_timeout(Duration::from_secs(5)).is_ok() } - /// As above, but for an arbitrary client type and url. + /// Configure a wallet against `url`, optionally through `proxy_addr`, for the + /// backends that talk to a listener the tests can watch. + #[cfg(any(feature = "electrum", feature = "esplora"))] fn setup_wallet_for( client_type: &str, url: &str, @@ -93,6 +103,45 @@ mod test_proxy { cmd } + /// Configure a wallet for `client_type` with `extra_args` appended to the + /// `config` command (e.g. `--proxy`, `--proxy_auth`, `--timeout`). No listener + /// is needed: these tests only exercise validation, which happens before any + /// connection is attempted, at `sync` time. + #[cfg(any(feature = "rpc", feature = "cbf"))] + fn setup_wallet_with_args(client_type: &str, extra_args: &[&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 mut cmd = cli.build_base_cmd(); + 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(client_type) + .arg("--database-type") + .arg("sqlite"); + // `--url` is required whenever electrum, esplora or rpc is built, no matter + // which `--client-type` is chosen; it does not exist at all otherwise. + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + cmd.arg("--url").arg("127.0.0.1:18443"); + cmd.args(extra_args).assert().success(); + + (cli, temp_dir) + } + /// With `--proxy` set, the connection must go to the proxy and never to the /// chain server directly. #[cfg(feature = "electrum")] @@ -164,46 +213,95 @@ mod test_proxy { ); } - /// A proxy the backend cannot honour is rejected, rather than ignored. + /// A proxy the backend cannot honour at all 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 (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--proxy", "127.0.0.1:9050"]); - 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"]; + sync_cmd(&cli) + .assert() + .failure() + .stderr(predicate::str::contains( + "rpc backend does not support a SOCKS5 proxy", + )); + } - 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") + /// `--proxy_auth` alone (no `--proxy`) is also rejected for rpc, not just + /// silently dropped. + #[cfg(all(feature = "rpc", any(feature = "electrum", feature = "esplora")))] + #[test] + fn test_rpc_backend_rejects_proxy_auth() { + let (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--proxy_auth", "user:password"]); + + sync_cmd(&cli) .assert() - .success(); + .failure() + .stderr(predicate::str::contains( + "rpc backend does not support --proxy_auth", + )); + } + + /// `--timeout` alone is likewise rejected for rpc. + #[cfg(all(feature = "rpc", any(feature = "electrum", feature = "esplora")))] + #[test] + fn test_rpc_backend_rejects_timeout() { + let (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--timeout", "30"]); sync_cmd(&cli) .assert() .failure() .stderr(predicate::str::contains( - "rpc backend does not support a SOCKS5 proxy", + "rpc backend does not support --timeout", + )); + } + + /// Kyoto takes the proxy as a `SocketAddr`, so a hostname is reported rather + /// than accepted and then failing obscurely. + #[cfg(feature = "cbf")] + #[test] + fn test_cbf_backend_rejects_a_proxy_hostname() { + let (cli, _temp_dir) = setup_wallet_with_args("cbf", &["--proxy", "tor.local:9050"]); + + sync_cmd(&cli) + .assert() + .failure() + .stderr(predicate::str::contains( + "cbf backend needs --proxy as an ip:port address", + )); + } + + /// Kyoto's proxy carries no credentials, so `--proxy_auth` is reported rather + /// than silently dropped. + #[cfg(all(feature = "cbf", any(feature = "electrum", feature = "esplora")))] + #[test] + fn test_cbf_backend_rejects_proxy_auth() { + let (cli, _temp_dir) = setup_wallet_with_args( + "cbf", + &["--proxy", "127.0.0.1:9050", "--proxy_auth", "user:password"], + ); + + sync_cmd(&cli) + .assert() + .failure() + .stderr(predicate::str::contains( + "cbf backend does not support --proxy_auth", + )); + } + + /// Kyoto has no proxy-specific timeout knob, so `--timeout` is reported rather + /// than silently dropped. + #[cfg(all(feature = "cbf", any(feature = "electrum", feature = "esplora")))] + #[test] + fn test_cbf_backend_rejects_timeout() { + let (cli, _temp_dir) = + setup_wallet_with_args("cbf", &["--proxy", "127.0.0.1:9050", "--timeout", "30"]); + + sync_cmd(&cli) + .assert() + .failure() + .stderr(predicate::str::contains( + "cbf backend does not support --timeout", )); } }