diff --git a/contrib/lianad_config_example.toml b/contrib/lianad_config_example.toml index e7145faf..462ac8cc 100644 --- a/contrib/lianad_config_example.toml +++ b/contrib/lianad_config_example.toml @@ -31,8 +31,10 @@ poll_interval_secs = 30 # This section is specific to the bitcoind implementation of the Bitcoin backend. This is the only # implementation available for now. -# In order to be able to connect to bitcoind, it needs to know on what port it is listening as well -# as where the authentication cookie is located. +# In order to be able to connect to bitcoind, it needs to know on what port it is listening and +# how to authenticate, either by specifying the cookie location with "cookie_path" or otherwise +# passing a colon-separated user and password with "auth". [bitcoind_config] addr = "127.0.0.1:18332" cookie_path = "/home/wizardsardine/.bitcoin/testnet3/.cookie" +# auth = "my_user:my_password" diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs index 601ca10c..e356f3e8 100644 --- a/src/bitcoin/d/mod.rs +++ b/src/bitcoin/d/mod.rs @@ -228,34 +228,43 @@ impl BitcoinD { config: &config::BitcoindConfig, watchonly_wallet_path: String, ) -> Result { - let cookie_string = - fs::read_to_string(&config.cookie_path).map_err(BitcoindError::CookieFile)?; let node_url = format!("http://{}", config.addr); let watchonly_url = format!("http://{}/wallet/{}", config.addr, watchonly_wallet_path); + let builder = match &config.rpc_auth { + config::BitcoindRpcAuth::CookieFile(cookie_path) => { + let cookie_string = + fs::read_to_string(cookie_path).map_err(BitcoindError::CookieFile)?; + MinreqHttpTransport::builder().cookie_auth(cookie_string) + } + config::BitcoindRpcAuth::UserPass(user, pass) => { + MinreqHttpTransport::builder().basic_auth(user.clone(), Some(pass.clone())) + } + }; + // Create a dummy bitcoind with clients using a low timeout to sanity check the connection. let dummy_node_client = Client::with_transport( - MinreqHttpTransport::builder() + builder + .clone() .url(&node_url) .map_err(BitcoindError::from)? .timeout(Duration::from_secs(3)) - .cookie_auth(cookie_string.clone()) .build(), ); let sendonly_client = Client::with_transport( - MinreqHttpTransport::builder() + builder + .clone() .url(&watchonly_url) .map_err(BitcoindError::from)? .timeout(Duration::from_secs(1)) - .cookie_auth(cookie_string.clone()) .build(), ); let dummy_wo_client = Client::with_transport( - MinreqHttpTransport::builder() + builder + .clone() .url(&watchonly_url) .map_err(BitcoindError::from)? .timeout(Duration::from_secs(3)) - .cookie_auth(cookie_string.clone()) .build(), ); let dummy_bitcoind = BitcoinD { @@ -271,27 +280,26 @@ impl BitcoinD { // Now the connection is checked, create the clients with an appropriate timeout. let node_client = Client::with_transport( - MinreqHttpTransport::builder() + builder + .clone() .url(&node_url) .map_err(BitcoindError::from)? .timeout(Duration::from_secs(RPC_SOCKET_TIMEOUT)) - .cookie_auth(cookie_string.clone()) .build(), ); let sendonly_client = Client::with_transport( - MinreqHttpTransport::builder() + builder + .clone() .url(&watchonly_url) .map_err(BitcoindError::from)? .timeout(Duration::from_secs(1)) - .cookie_auth(cookie_string.clone()) .build(), ); let watchonly_client = Client::with_transport( - MinreqHttpTransport::builder() + builder .url(&watchonly_url) .map_err(BitcoindError::from)? .timeout(Duration::from_secs(RPC_SOCKET_TIMEOUT)) - .cookie_auth(cookie_string) .build(), ); Ok(BitcoinD { diff --git a/src/config.rs b/src/config.rs index 46152b88..a1a919d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,43 @@ pub fn serialize_duration(duration: &Duration, s: S) -> Result(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + pub struct BitcoindRpcAuthHelper { + cookie_path: Option, + auth: Option, + } + let BitcoindRpcAuthHelper { cookie_path, auth } = + BitcoindRpcAuthHelper::deserialize(deserializer)?; + let rpc_auth = match (cookie_path, auth) { + (Some(_), Some(_)) => { + return Err(de::Error::custom( + "must not set both `cookie_path` and `auth`", + )); + } + (Some(path), None) => BitcoindRpcAuth::CookieFile(path), + (None, Some(auth)) => auth + .split_once(':') + .ok_or(de::Error::custom("`auth` must be 'user:password'")) + .map(|(user, pass)| BitcoindRpcAuth::UserPass(user.to_string(), pass.to_string()))?, + (None, None) => { + return Err(de::Error::custom("must set either `cookie_path` or `auth`")); + } + }; + Ok(rpc_auth) +} + +fn serialize_userpass( + user: &String, + password: &String, + s: S, +) -> Result { + s.serialize_str(&format!("{}:{}", user, password)) +} + fn default_loglevel() -> log::LevelFilter { log::LevelFilter::Info } @@ -48,11 +85,23 @@ fn default_daemon() -> bool { false } +/// RPC authentication options. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum BitcoindRpcAuth { + /// Path to bitcoind's cookie file. + #[serde(rename = "cookie_path")] + CookieFile(PathBuf), + /// "USER:PASSWORD" for authentication. + #[serde(rename = "auth", serialize_with = "serialize_userpass")] + UserPass(String, String), +} + /// Everything we need to know for talking to bitcoind serenely #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BitcoindConfig { - /// Path to bitcoind's cookie file, to authenticate the RPC connection - pub cookie_path: PathBuf, + /// Authentication credentials for bitcoind's RPC server. + #[serde(flatten, deserialize_with = "deserialize_rpc_auth")] + pub rpc_auth: BitcoindRpcAuth, /// The IP:port bitcoind's RPC is listening on pub addr: SocketAddr, } @@ -217,7 +266,9 @@ impl Config { #[cfg(test)] mod tests { - use super::{config_file_path, Config}; + use std::path::PathBuf; + + use super::{config_file_path, BitcoindConfig, BitcoindRpcAuth, Config}; // Test the format of the configuration file #[test] @@ -259,6 +310,26 @@ mod tests { #[cfg(unix)] // On non-UNIX there is no 'daemon' member. assert_eq!(toml_str, serialized); + // A valid, round-tripping, config with `auth` instead of `cookie_path` + let toml_str = r#" + data_dir = '/home/wizardsardine/custom/folder/' + daemon = false + log_level = 'TRACE' + main_descriptor = 'wsh(andor(pk([aabbccdd]tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(10000),pk([aabbccdd]tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))#dw4ulnrs' + + [bitcoin_config] + network = 'bitcoin' + poll_interval_secs = 18 + + [bitcoind_config] + auth = 'my_user:my_password' + addr = '127.0.0.1:8332' + "#.trim_start().replace(" ", ""); + let parsed = toml::from_str::(&toml_str).expect("Deserializing toml_str"); + let serialized = toml::to_string_pretty(&parsed).expect("Serializing to toml"); + #[cfg(unix)] // On non-UNIX there is no 'daemon' member. + assert_eq!(toml_str, serialized); + // Invalid desc checksum let toml_str = r#" daemon = false @@ -298,6 +369,81 @@ mod tests { config_res.expect_err("Deserializing an invalid toml_str"); } + // Test the format of the bitcoind_config section + #[test] + fn toml_bitcoind_config() { + // A valid config with cookie_path + let toml_str = r#" + cookie_path = '/home/user/.bitcoin/.cookie' + addr = '127.0.0.1:8332' + "# + .trim_start() + .replace(" ", ""); + toml::from_str::(&toml_str).expect("Deserializing toml_str"); + let parsed = toml::from_str::(&toml_str).expect("Deserializing toml_str"); + let serialized = toml::to_string_pretty(&parsed).expect("Serializing to toml"); + assert_eq!(toml_str, serialized); + assert_eq!( + parsed.rpc_auth, + BitcoindRpcAuth::CookieFile(PathBuf::from("/home/user/.bitcoin/.cookie")) + ); + + // A valid config with auth + let toml_str = r#" + auth = 'my_user:my_password' + addr = '127.0.0.1:8332' + "# + .trim_start() + .replace(" ", ""); + toml::from_str::(&toml_str).expect("Deserializing toml_str"); + let parsed = toml::from_str::(&toml_str).expect("Deserializing toml_str"); + let serialized = toml::to_string_pretty(&parsed).expect("Serializing to toml"); + assert_eq!(toml_str, serialized); + assert_eq!( + parsed.rpc_auth, + BitcoindRpcAuth::UserPass("my_user".to_string(), "my_password".to_string()) + ); + + // Must not set both cookie_file and auth + let toml_str = r#" + cookie_path = '/home/user/.bitcoin/.cookie' + auth = 'my_user:my_password' + addr = '127.0.0.1:8332' + "# + .trim_start() + .replace(" ", ""); + let config_err = toml::from_str::(&toml_str) + .expect_err("Deserializing an invalid toml_str"); + assert!(config_err + .to_string() + .contains("must not set both `cookie_path` and `auth`")); + + // Missing RPC credentials + let toml_str = r#" + addr = '127.0.0.1:8332' + "# + .trim_start() + .replace(" ", ""); + let config_err = toml::from_str::(&toml_str) + .expect_err("Deserializing an invalid toml_str"); + assert!(config_err + .to_string() + .contains("must set either `cookie_path` or `auth`")); + + // Missing colon in auth + let toml_str = r#" + auth = 'my_usermy_password' + addr = '127.0.0.1:8332' + "# + .trim_start() + .replace(" ", ""); + let config_err = toml::from_str::(&toml_str) + .expect_err("Deserializing an invalid toml_str"); + assert!(config_err + .to_string() + .contains("`auth` must be 'user:password'")); + } + #[test] fn config_directory() { let filepath = config_file_path().expect("Getting config file path"); diff --git a/src/lib.rs b/src/lib.rs index 5f54a9d5..a58b6b30 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -433,7 +433,7 @@ impl DaemonHandle { mod tests { use super::*; use crate::{ - config::{BitcoinConfig, BitcoindConfig}, + config::{BitcoinConfig, BitcoindConfig, BitcoindRpcAuth}, descriptors::LianaDescriptor, testutils::*, }; @@ -659,7 +659,7 @@ mod tests { }; let bitcoind_config = BitcoindConfig { addr, - cookie_path: cookie, + rpc_auth: BitcoindRpcAuth::CookieFile(cookie), }; // Create a dummy config with this bitcoind