Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
73 changes: 70 additions & 3 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -202,6 +206,54 @@ pub struct KyotoClientHandle {
tokio::sync::Mutex<bdk_kyoto::UpdateSubscriber<bdk_kyoto::wallets::Single>>,
}

/// 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<String> {
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",
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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)
Expand Down
150 changes: 147 additions & 3 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -284,24 +285,81 @@ 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.
#[arg(env = "PROXY_ADDRS:PORT", long = "proxy")]
pub proxy: Option<String>,

/// 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',
Expand All @@ -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<u8>,
}

#[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<Option<std::net::SocketAddr>, 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)]
Expand Down Expand Up @@ -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::<SocketAddr>().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);
}
}
11 changes: 7 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub struct WalletConfigInner {
pub parallel_requests: Option<usize>,
#[cfg(feature = "rpc")]
pub cookie: Option<String>,
#[cfg(any(feature = "electrum", feature = "esplora"))]
#[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
#[serde(default)]
pub proxy: Option<String>,
#[cfg(any(feature = "electrum", feature = "esplora"))]
Expand Down Expand Up @@ -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,
},

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ impl AppCommand<AppContext<Init>> 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
Expand Down
1 change: 1 addition & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ mod integration {
mod init;
mod offline;
mod online;
mod proxy;
}
Loading
Loading