diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d127b14..f2bf0503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ 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 +- 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 54c28d55..bce66a18 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,54 @@ 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}"), + }) +} + #[cfg(any( feature = "electrum", feature = "esplora", @@ -219,7 +271,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 +281,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 +298,7 @@ pub(crate) fn new_blockchain_client( #[cfg(feature = "rpc")] ClientType::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( @@ -253,8 +315,13 @@ pub(crate) fn new_blockchain_client( #[cfg(feature = "cbf")] ClientType::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..d921e7ce 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; @@ -270,7 +271,7 @@ pub struct WalletOpts { #[cfg(feature = "rpc")] /// Sets the rpc basic authentication. #[arg( - env = "USER:PASSWD", + env = "RPC_BASIC_AUTH", short = 'a', long, value_parser = parse_proxy_auth, @@ -284,24 +285,83 @@ 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." + ))); + } + 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." + ))); + } + 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. - #[arg(env = "PROXY_ADDRS:PORT", long = "proxy")] + #[arg(env = "PROXY_ADDRS_PORT", long = "proxy")] pub proxy: Option, /// Sets the SOCKS5 proxy credential. - #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", value_parser = parse_proxy_auth)] + #[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 +371,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 +519,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/src/handlers/dns/mod.rs b/src/handlers/dns/mod.rs index a5733690..645672c1 100644 --- a/src/handlers/dns/mod.rs +++ b/src/handlers/dns/mod.rs @@ -41,7 +41,7 @@ impl AsyncAppCommand> for ResolveDnsRecipientCommand { /// Creates a new unsigned transaction from DNS payment instructions. #[derive(Parser, Debug, Clone, PartialEq)] pub struct CreateDnsTxCommand { - #[arg(env = "ADDRESS:SAT", long = "to", value_parser = parse_recipient)] + #[arg(env = "ADDRESS_SAT", long = "to", value_parser = parse_recipient)] pub recipients: Vec<(ScriptBuf, u64)>, #[arg(long = "to_dns", value_parser = parse_dns_recipient)] pub dns_recipients: Vec<(String, u64)>, @@ -53,9 +53,9 @@ pub struct CreateDnsTxCommand { pub enable_rbf: bool, #[arg(long = "offline_signer")] pub offline_signer: bool, - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + #[arg(env = "MUST_SPEND_TXID_VOUT", long = "utxos", value_parser = parse_outpoint)] pub utxos: Option>, - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + #[arg(env = "CANT_SPEND_TXID_VOUT", long = "unspendable", value_parser = parse_outpoint)] pub unspendable: Option>, #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate", value_parser = parse_fee_rate)] pub fee_rate: Option, diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 4b623b49..15cdd26b 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -193,7 +193,7 @@ impl AppCommand>> for BalanceCommand { #[derive(Debug, Parser, Clone, PartialEq)] pub struct CreateTxCommand { /// Adds a recipient to the transaction. - #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] + #[arg(env = "ADDRESS_SAT", long = "to", required = true, value_parser = parse_recipient)] pub recipients: Vec<(ScriptBuf, u64)>, /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. @@ -209,11 +209,11 @@ pub struct CreateTxCommand { pub offline_signer: bool, /// Selects which utxos *must* be spent. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + #[arg(env = "MUST_SPEND_TXID_VOUT", long = "utxos", value_parser = parse_outpoint)] pub utxos: Option>, /// Marks a utxo as unspendable. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + #[arg(env = "CANT_SPEND_TXID_VOUT", long = "unspendable", value_parser = parse_outpoint)] pub unspendable: Option>, /// Fee rate to use in sat/vbyte. @@ -327,7 +327,7 @@ pub struct CreateSpTxCommand { /// Adds a recipient to the transaction. // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. // Address and amount parsing is done at run time in handler function. - #[arg(env = "ADDRESS:SAT", long = "to", required = false, value_parser = parse_recipient)] + #[arg(env = "ADDRESS_SAT", long = "to", required = false, value_parser = parse_recipient)] pub recipients: Option>, /// Parse silent payment recipients #[arg(long = "to-sp", required = true, value_parser = parse_sp_code_value_pairs)] @@ -339,10 +339,10 @@ pub struct CreateSpTxCommand { #[arg(long = "offline_signer")] pub offline_signer: bool, /// Selects which utxos *must* be spent. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + #[arg(env = "MUST_SPEND_TXID_VOUT", long = "utxos", value_parser = parse_outpoint)] pub utxos: Option>, /// Marks a utxo as unspendable. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + #[arg(env = "CANT_SPEND_TXID_VOUT", long = "unspendable", value_parser = parse_outpoint)] pub unspendable: Option>, /// Fee rate to use in sat/vbyte. #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate", value_parser = parse_fee_rate)] @@ -547,11 +547,11 @@ pub struct BumpFeeCommand { pub offline_signer: bool, /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + #[arg(env = "MUST_SPEND_TXID_VOUT", long = "utxos", value_parser = parse_outpoint)] pub utxos: Option>, /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + #[arg(env = "CANT_SPEND_TXID_VOUT", long = "unspendable", value_parser = parse_outpoint)] pub unspendable: Option>, /// The new targeted fee rate in sat/vbyte. 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/common/mod.rs b/tests/common/mod.rs index 43220166..d38edf8b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -40,14 +40,14 @@ impl BdkCli { pub fn build_base_cmd(&self) -> Command { let mut cmd = Command::cargo_bin("bdk-cli").expect("bdk-cli binary must compile"); - cmd.arg("--network").arg(&self.network); + cmd.args(["--network", &self.network]); if let Some(dir) = &self.datadir { cmd.arg("--datadir").arg(dir); } if let Some(url) = &self.server_url { - cmd.arg("--server").arg(url); + cmd.args(["--server", url]); } cmd diff --git a/tests/integration/init.rs b/tests/integration/init.rs index 0a2af96f..4f57b460 100644 --- a/tests/integration/init.rs +++ b/tests/integration/init.rs @@ -138,19 +138,13 @@ mod test_wallets { cli.build_base_cmd() .arg("wallet") - .arg("--wallet") - .arg(wallet_name) + .args(["--wallet", wallet_name]) .arg("config") - .arg("--ext-descriptor") - .arg(ext_desc) - .arg("--int-descriptor") - .arg(int_desc) - .arg("--client-type") - .arg("rpc") - .arg("--database-type") - .arg("sqlite") - .arg("--url") - .arg("http://localhost:18443") + .args(["--ext-descriptor", ext_desc]) + .args(["--int-descriptor", int_desc]) + .args(["--client-type", "rpc"]) + .args(["--database-type", "sqlite"]) + .args(["--url", "http://localhost:18443"]) .assert() .success(); } @@ -246,19 +240,13 @@ mod test_config { let mut cmd_init = cli.build_base_cmd(); cmd_init .arg("wallet") - .arg("--wallet") - .arg(wallet_name) + .args(["--wallet", 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(db) - .arg("--url") - .arg(url); + .args(["--ext-descriptor", ext_desc]) + .args(["--int-descriptor", int_desc]) + .args(["--client-type", client_type]) + .args(["--database-type", db]) + .args(["--url", url]); cmd_init.assert().success(); @@ -310,19 +298,15 @@ mod test_config { cli.build_base_cmd() .arg("wallet") - .arg("--wallet") - .arg("secret_wallet") + .args(["--wallet", "secret_wallet"]) .arg("config") .arg("--ext-descriptor") .arg(priv_desc["external"].as_str().unwrap()) .arg("--int-descriptor") .arg(priv_desc["internal"].as_str().unwrap()) - .arg("--client-type") - .arg("rpc") - .arg("--database-type") - .arg("sqlite") - .arg("--url") - .arg("http://localhost:18443") + .args(["--client-type", "rpc"]) + .args(["--database-type", "sqlite"]) + .args(["--url", "http://localhost:18443"]) .assert() .success() .stderr(predicate::str::contains("PRIVATE KEYS")); diff --git a/tests/integration/offline.rs b/tests/integration/offline.rs index 4b678eba..417bf72b 100644 --- a/tests/integration/offline.rs +++ b/tests/integration/offline.rs @@ -35,19 +35,13 @@ mod test_offline { let mut cmd_init = cli.build_base_cmd(); cmd_init .arg("wallet") - .arg("--wallet") - .arg(WALLET_NAME) + .args(["--wallet", 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(db) - .arg("--url") - .arg(url); + .args(["--ext-descriptor", ext_desc]) + .args(["--int-descriptor", int_desc]) + .args(["--client-type", client_type]) + .args(["--database-type", db]) + .args(["--url", url]); (cli, cmd_init) } @@ -438,13 +432,10 @@ mod multipath_tests { fn save_config(cli: &BdkCli, wallet: &str, ext: &str, int: Option<&str>) -> Command { let mut cmd = cli.build_base_cmd(); cmd.arg("wallet") - .arg("--wallet") - .arg(wallet) + .args(["--wallet", wallet]) .arg("config") - .arg("--ext-descriptor") - .arg(ext) - .arg("--database-type") - .arg("sqlite"); + .args(["--ext-descriptor", ext]) + .args(["--database-type", "sqlite"]); if let Some(int) = int { cmd.arg("--int-descriptor").arg(int); } diff --git a/tests/integration/online.rs b/tests/integration/online.rs index b9dcc7b4..4d3f11af 100644 --- a/tests/integration/online.rs +++ b/tests/integration/online.rs @@ -33,19 +33,13 @@ mod test_online { let mut cmd_init = cli.build_base_cmd(); cmd_init .arg("wallet") - .arg("--wallet") - .arg(WALLET_NAME) + .args(["--wallet", WALLET_NAME]) .arg("config") - .arg("--ext-descriptor") - .arg(ext_desc) - .arg("--int-descriptor") - .arg(int_desc) - .arg("--client-type") - .arg("electrum") - .arg("--database-type") - .arg("sqlite") - .arg("--url") - .arg(server_url); + .args(["--ext-descriptor", ext_desc]) + .args(["--int-descriptor", int_desc]) + .args(["--client-type", "electrum"]) + .args(["--database-type", "sqlite"]) + .args(["--url", server_url]); (cli, cmd_init, env) } @@ -862,19 +856,13 @@ mod test_online { .unwrap(); cli.build_base_cmd() .arg("wallet") - .arg("--wallet") - .arg(WALLET_NAME) + .args(["--wallet", WALLET_NAME]) .arg("config") - .arg("--ext-descriptor") - .arg(ext_desc) - .arg("--int-descriptor") - .arg(int_desc) - .arg("--client-type") - .arg("electrum") - .arg("--database-type") - .arg("sqlite") - .arg("--url") - .arg(server_url) + .args(["--ext-descriptor", ext_desc]) + .args(["--int-descriptor", int_desc]) + .args(["--client-type", "electrum"]) + .args(["--database-type", "sqlite"]) + .args(["--url", server_url]) .assert() .success(); diff --git a/tests/integration/proxy.rs b/tests/integration/proxy.rs new file mode 100644 index 00000000..9b369468 --- /dev/null +++ b/tests/integration/proxy.rs @@ -0,0 +1,298 @@ +//! 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", feature = "cbf"))] +mod test_proxy { + use crate::common::BdkCli; + use assert_cmd::Command; + #[cfg(any(feature = "rpc", feature = "cbf"))] + use predicates::prelude::*; + use serde_json::Value; + use std::time::Duration; + use tempfile::TempDir; + #[cfg(any(feature = "electrum", feature = "esplora"))] + use { + std::net::{TcpListener, TcpStream}, + std::sync::mpsc::{Receiver, channel}, + std::thread, + }; + + 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(); + 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) + } + + #[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() + } + + /// 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, + 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") + .args(["--wallet", WALLET_NAME]) + .arg("config") + .args(["--ext-descriptor", ext_desc]) + .args(["--int-descriptor", int_desc]) + .args(["--client-type", client_type]) + .args(["--database-type", "sqlite"]) + .args(["--url", 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 + } + + /// 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") + .args(["--wallet", WALLET_NAME]) + .arg("config") + .arg("--ext-descriptor") + .arg(public["external"].as_str().unwrap()) + .arg("--int-descriptor") + .arg(public["internal"].as_str().unwrap()) + .args(["--client-type", client_type]) + .args(["--database-type", "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")] + #[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 at all is rejected, rather than ignored. + #[cfg(feature = "rpc")] + #[test] + fn test_rpc_backend_rejects_a_proxy() { + let (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--proxy", "127.0.0.1:9050"]); + + sync_cmd(&cli) + .assert() + .failure() + .stderr(predicate::str::contains( + "rpc backend does not support a SOCKS5 proxy", + )); + } + + /// `--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() + .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 --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", + )); + } +}