From bc35cdaad8e18b0942b68de1d3a054232b2f0db1 Mon Sep 17 00:00:00 2001 From: jp1ac4 <121959000+jp1ac4@users.noreply.github.com> Date: Thu, 24 Aug 2023 10:54:20 +0100 Subject: [PATCH] installer: move bitcoind steps to own module --- gui/src/installer/step/bitcoind.rs | 770 ++++++++++++++++++++++++++++ gui/src/installer/step/mod.rs | 777 +---------------------------- 2 files changed, 779 insertions(+), 768 deletions(-) create mode 100644 gui/src/installer/step/bitcoind.rs diff --git a/gui/src/installer/step/bitcoind.rs b/gui/src/installer/step/bitcoind.rs new file mode 100644 index 00000000..91358cd6 --- /dev/null +++ b/gui/src/installer/step/bitcoind.rs @@ -0,0 +1,770 @@ +use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use iced::Command; +use liana::{config::BitcoindConfig, miniscript::bitcoin::Network}; + +use tracing::info; + +use jsonrpc::{client::Client, simple_http::SimpleHttpTransport}; + +use liana_ui::{component::form, widget::*}; + +use crate::{ + bitcoind::{start_internal_bitcoind, stop_internal_bitcoind, StartInternalBitcoindError}, + installer::{ + context::Context, + internal_bitcoind_datadir, + message::{self, Message}, + step::Step, + view, Error, InternalBitcoindExeConfig, + }, + utils::poll_for_file, +}; + +pub struct DefineBitcoind { + cookie_path: form::Value, + address: form::Value, + is_running: Option>, +} + +pub struct InternalBitcoindStep { + bitcoind_datadir: PathBuf, + network: Network, + started: Option>, + exe_path: Option, + bitcoind_config: Option, + exe_config: Option, + internal_bitcoind_config: Option, + error: Option, +} + +pub struct SelectBitcoindTypeStep { + use_external: bool, +} + +/// Default prune value used by internal bitcoind. +pub const PRUNE_DEFAULT: u32 = 15_000; +/// Default ports used by bitcoind across all networks. +pub const BITCOIND_DEFAULT_PORTS: [u16; 8] = [8332, 8333, 18332, 18333, 18443, 18444, 38332, 38333]; + +/// Represents section for a single network in `bitcoin.conf` file. +#[derive(PartialEq, Eq, Debug, Clone)] +pub struct InternalBitcoindNetworkConfig { + rpc_port: u16, + p2p_port: u16, + prune: u32, +} + +/// Represents the `bitcoin.conf` file to be used by internal bitcoind. +#[derive(Debug, Clone)] +pub struct InternalBitcoindConfig { + networks: BTreeMap, +} + +#[derive(PartialEq, Eq, Debug, Clone)] +pub enum InternalBitcoindConfigError { + KeyNotFound(String), + CouldNotParseValue(String), + UnexpectedSection(String), + TooManyElements(String), + FileNotFound, + ReadingFile(String), + WritingFile(String), + Unexpected(String), +} + +impl std::fmt::Display for InternalBitcoindConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::KeyNotFound(e) => write!(f, "Config file does not contain expected key: {}", e), + Self::CouldNotParseValue(e) => write!(f, "Value could not be parsed: {}", e), + Self::UnexpectedSection(e) => write!(f, "Unexpected section in file: {}", e), + Self::TooManyElements(section) => { + write!(f, "Section in file contains too many elements: {}", section) + } + Self::FileNotFound => write!(f, "File not found"), + Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e), + Self::WritingFile(e) => write!(f, "Error while writing file: {}", e), + Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), + } + } +} + +impl Default for InternalBitcoindConfig { + fn default() -> Self { + Self::new() + } +} + +impl InternalBitcoindConfig { + pub fn new() -> Self { + Self { + networks: BTreeMap::new(), + } + } + + pub fn from_ini(ini: &ini::Ini) -> Result { + let mut networks = BTreeMap::new(); + for (maybe_sec, prop) in ini { + if let Some(sec) = maybe_sec { + let network = Network::from_core_arg(sec) + .map_err(|e| InternalBitcoindConfigError::UnexpectedSection(e.to_string()))?; + if prop.len() > 3 { + return Err(InternalBitcoindConfigError::TooManyElements( + sec.to_string(), + )); + } + let rpc_port = prop + .get("rpcport") + .ok_or_else(|| InternalBitcoindConfigError::KeyNotFound("rpcport".to_string()))? + .parse::() + .map_err(|e| InternalBitcoindConfigError::CouldNotParseValue(e.to_string()))?; + let p2p_port = prop + .get("port") + .ok_or_else(|| InternalBitcoindConfigError::KeyNotFound("port".to_string()))? + .parse::() + .map_err(|e| InternalBitcoindConfigError::CouldNotParseValue(e.to_string()))?; + let prune = prop + .get("prune") + .ok_or_else(|| InternalBitcoindConfigError::KeyNotFound("prune".to_string()))? + .parse::() + .map_err(|e| InternalBitcoindConfigError::CouldNotParseValue(e.to_string()))?; + networks.insert( + network, + InternalBitcoindNetworkConfig { + rpc_port, + p2p_port, + prune, + }, + ); + } else if !prop.is_empty() { + return Err(InternalBitcoindConfigError::UnexpectedSection( + "General section should be empty".to_string(), + )); + } + } + Ok(Self { networks }) + } + + pub fn from_file(path: &PathBuf) -> Result { + if !path.exists() { + return Err(InternalBitcoindConfigError::FileNotFound); + } + let conf_ini = ini::Ini::load_from_file(path) + .map_err(|e| InternalBitcoindConfigError::ReadingFile(e.to_string()))?; + + Self::from_ini(&conf_ini) + } + + pub fn to_ini(&self) -> ini::Ini { + let mut conf_ini = ini::Ini::new(); + + for (network, network_conf) in &self.networks { + conf_ini + .with_section(Some(network.to_core_arg())) + .set("rpcport", network_conf.rpc_port.to_string()) + .set("port", network_conf.p2p_port.to_string()) + .set("prune", network_conf.prune.to_string()); + } + conf_ini + } + + pub fn to_file(&self, path: &PathBuf) -> Result<(), InternalBitcoindConfigError> { + std::fs::create_dir_all( + path.parent() + .ok_or_else(|| InternalBitcoindConfigError::Unexpected("No parent".to_string()))?, + ) + .map_err(|e| InternalBitcoindConfigError::Unexpected(e.to_string()))?; + info!("Writing to file {}", path.to_string_lossy()); + self.to_ini() + .write_to_file(path) + .map_err(|e| InternalBitcoindConfigError::WritingFile(e.to_string()))?; + + Ok(()) + } +} + +/// Path of the `bitcoin.conf` file used by internal bitcoind. +fn internal_bitcoind_config_path(bitcoind_datadir: &PathBuf) -> PathBuf { + let mut config_path = PathBuf::from(bitcoind_datadir); + config_path.push("bitcoin.conf"); + config_path +} + +/// Path of the cookie file used by internal bitcoind on a given network. +fn internal_bitcoind_cookie_path(bitcoind_datadir: &Path, network: &Network) -> PathBuf { + let mut cookie_path = bitcoind_datadir.to_path_buf(); + if let Some(dir) = bitcoind_network_dir(network) { + cookie_path.push(dir); + } + cookie_path.push(".cookie"); + cookie_path +} + +/// RPC address for internal bitcoind. +fn internal_bitcoind_address(rpc_port: u16) -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), rpc_port) +} + +fn bitcoind_default_datadir() -> Option { + #[cfg(target_os = "linux")] + let configs_dir = dirs::home_dir(); + + #[cfg(not(target_os = "linux"))] + let configs_dir = dirs::config_dir(); + + if let Some(mut path) = configs_dir { + #[cfg(target_os = "linux")] + path.push(".bitcoin"); + + #[cfg(not(target_os = "linux"))] + path.push("Bitcoin"); + + return Some(path); + } + None +} + +fn bitcoind_network_dir(network: &Network) -> Option { + let dir = match network { + Network::Bitcoin => { + return None; + } + Network::Testnet => "testnet3", + Network::Regtest => "regtest", + Network::Signet => "signet", + _ => panic!("Directory required for this network is unknown."), + }; + Some(dir.to_string()) +} + +fn bitcoind_default_cookie_path(network: &Network) -> Option { + if let Some(mut path) = bitcoind_default_datadir() { + if let Some(dir) = bitcoind_network_dir(network) { + path.push(dir); + } + path.push(".cookie"); + return path.to_str().map(|s| s.to_string()); + } + None +} + +fn bitcoind_default_address(network: &Network) -> String { + match network { + Network::Bitcoin => "127.0.0.1:8332".to_string(), + Network::Testnet => "127.0.0.1:18332".to_string(), + Network::Regtest => "127.0.0.1:18443".to_string(), + Network::Signet => "127.0.0.1:38332".to_string(), + _ => "127.0.0.1:8332".to_string(), + } +} + +/// Looks for bitcoind executable path and returns `None` if not found. +fn bitcoind_exe_path() -> Option { + which::which("bitcoind").ok() +} + +/// Get available port that is valid for use by internal bitcoind. +// Modified from https://github.com/RCasatta/bitcoind/blob/f047740d7d0af935ff7360cf77429c5f294cfd59/src/lib.rs#L435 +pub fn get_available_port() -> Result { + // Perform multiple attempts to get a valid port. + for _ in 0..10 { + // Using 0 as port lets the system assign a port available. + let t = TcpListener::bind(("127.0.0.1", 0)) + .map_err(|e| Error::CannotGetAvailablePort(e.to_string()))?; + let port = t + .local_addr() + .map(|s| s.port()) + .map_err(|e| Error::CannotGetAvailablePort(e.to_string()))?; + if port_is_valid(&port) { + return Ok(port); + } + } + Err(Error::CannotGetAvailablePort( + "Exhausted attempts".to_string(), + )) +} + +/// Checks if port is valid for use by internal bitcoind. +pub fn port_is_valid(port: &u16) -> bool { + !BITCOIND_DEFAULT_PORTS.contains(port) +} + +impl Default for SelectBitcoindTypeStep { + fn default() -> Self { + Self::new() + } +} + +impl From for Box { + fn from(s: SelectBitcoindTypeStep) -> Box { + Box::new(s) + } +} + +impl SelectBitcoindTypeStep { + pub fn new() -> Self { + Self { use_external: true } + } +} + +impl Step for SelectBitcoindTypeStep { + fn update(&mut self, message: Message) -> Command { + if let Message::SelectBitcoindType(msg) = message { + match msg { + message::SelectBitcoindTypeMsg::UseExternal(selected) => { + self.use_external = selected; + } + }; + return Command::perform(async {}, |_| Message::Next); + }; + Command::none() + } + + fn apply(&mut self, ctx: &mut Context) -> bool { + if !self.use_external { + if ctx.internal_bitcoind_config.is_none() { + ctx.bitcoind_config = None; // Ensures internal bitcoind can be restarted in case user has switched selection. + } + } else { + ctx.internal_bitcoind_config = None; + ctx.internal_bitcoind_exe_config = None; + } + ctx.bitcoind_is_external = self.use_external; + true + } + + fn view(&self, progress: (usize, usize)) -> Element { + view::select_bitcoind_type(progress) + } +} + +impl DefineBitcoind { + pub fn new() -> Self { + Self { + cookie_path: form::Value::default(), + address: form::Value::default(), + is_running: None, + } + } + + pub fn ping(&self) -> Command { + let address = self.address.value.to_owned(); + let cookie_path = self.cookie_path.value.to_owned(); + Command::perform( + async move { + let cookie = std::fs::read_to_string(&cookie_path) + .map_err(|e| Error::Bitcoind(format!("Failed to read cookie file: {}", e)))?; + let client = Client::with_transport( + SimpleHttpTransport::builder() + .url(&address)? + .timeout(std::time::Duration::from_secs(3)) + .cookie_auth(cookie) + .build(), + ); + client.send_request(client.build_request("echo", &[]))?; + Ok(()) + }, + |res| Message::DefineBitcoind(message::DefineBitcoind::PingBitcoindResult(res)), + ) + } +} + +impl Step for DefineBitcoind { + fn load_context(&mut self, ctx: &Context) { + if self.cookie_path.value.is_empty() { + self.cookie_path.value = + bitcoind_default_cookie_path(&ctx.bitcoin_config.network).unwrap_or_default() + } + if self.address.value.is_empty() { + self.address.value = bitcoind_default_address(&ctx.bitcoin_config.network); + } + } + fn update(&mut self, message: Message) -> Command { + if let Message::DefineBitcoind(msg) = message { + match msg { + message::DefineBitcoind::PingBitcoind => { + self.is_running = None; + return self.ping(); + } + message::DefineBitcoind::PingBitcoindResult(res) => self.is_running = Some(res), + message::DefineBitcoind::AddressEdited(address) => { + self.is_running = None; + self.address.value = address; + self.address.valid = true; + } + message::DefineBitcoind::CookiePathEdited(path) => { + self.is_running = None; + self.cookie_path.value = path; + self.address.valid = true; + } + }; + }; + Command::none() + } + + fn apply(&mut self, ctx: &mut Context) -> bool { + match ( + PathBuf::from_str(&self.cookie_path.value), + std::net::SocketAddr::from_str(&self.address.value), + ) { + (Err(_), Ok(_)) => { + self.cookie_path.valid = false; + false + } + (Ok(_), Err(_)) => { + self.address.valid = false; + false + } + (Err(_), Err(_)) => { + self.cookie_path.valid = false; + self.address.valid = false; + false + } + (Ok(path), Ok(addr)) => { + ctx.bitcoind_config = Some(BitcoindConfig { + cookie_path: path, + addr, + }); + true + } + } + } + + fn view(&self, progress: (usize, usize)) -> Element { + view::define_bitcoin( + progress, + &self.address, + &self.cookie_path, + self.is_running.as_ref(), + ) + } + + fn load(&self) -> Command { + self.ping() + } + + fn skip(&self, ctx: &Context) -> bool { + !ctx.bitcoind_is_external + } +} + +impl Default for DefineBitcoind { + fn default() -> Self { + Self::new() + } +} + +impl From for Box { + fn from(s: DefineBitcoind) -> Box { + Box::new(s) + } +} + +impl From for Box { + fn from(s: InternalBitcoindStep) -> Box { + Box::new(s) + } +} + +impl InternalBitcoindStep { + pub fn new(liana_datadir: &PathBuf) -> Self { + Self { + bitcoind_datadir: internal_bitcoind_datadir(liana_datadir), + network: Network::Bitcoin, + started: None, + exe_path: None, + bitcoind_config: None, + exe_config: None, + internal_bitcoind_config: None, + error: None, + } + } +} + +impl Step for InternalBitcoindStep { + fn load_context(&mut self, ctx: &Context) { + if self.exe_path.is_none() { + self.exe_path = if let Some(exe_config) = ctx.internal_bitcoind_exe_config.clone() { + Some(exe_config.exe_path) + } else { + bitcoind_exe_path() + }; + } + self.network = ctx.bitcoin_config.network; + if let Some(Ok(_)) = self.started { + // This case can arise if a user switches from internal bitcoind to external and back to internal. + if ctx.bitcoind_config.is_none() { + self.started = None; // So that internal bitcoind will be restarted. + } + } + } + fn update(&mut self, message: Message) -> Command { + if let Message::InternalBitcoind(msg) = message { + match msg { + message::InternalBitcoindMsg::Previous => { + if self.internal_bitcoind_config.is_some() { + if let Some(bitcoind_config) = &self.bitcoind_config { + stop_internal_bitcoind(bitcoind_config); + } + } + return Command::perform(async {}, |_| Message::Previous); + } + message::InternalBitcoindMsg::Reload => { + return self.load(); + } + message::InternalBitcoindMsg::DefineConfig => { + let mut conf = match InternalBitcoindConfig::from_file( + &internal_bitcoind_config_path(&self.bitcoind_datadir), + ) { + Ok(conf) => conf, + Err(InternalBitcoindConfigError::FileNotFound) => { + InternalBitcoindConfig::new() + } + Err(e) => { + self.error = Some(e.to_string()); + return Command::none(); + } + }; + // Insert entry for network if not present. + if conf.networks.get(&self.network).is_none() { + let network_conf = match (get_available_port(), get_available_port()) { + (Ok(rpc_port), Ok(p2p_port)) => { + // In case ports are the same, user will need to click button again for another attempt. + if rpc_port == p2p_port { + self.error = Some( + "Could not get distinct ports. Please try again." + .to_string(), + ); + return Command::none(); + } + InternalBitcoindNetworkConfig { + rpc_port, + p2p_port, + prune: PRUNE_DEFAULT, + } + } + (Ok(_), Err(e)) | (Err(e), Ok(_)) => { + self.error = Some(format!("Could not get available port: {}.", e)); + return Command::none(); + } + (Err(e1), Err(e2)) => { + self.error = + Some(format!("Could not get available ports: {}; {}.", e1, e2)); + return Command::none(); + } + }; + conf.networks.insert(self.network, network_conf); + } + if let Err(e) = + conf.to_file(&internal_bitcoind_config_path(&self.bitcoind_datadir)) + { + self.error = Some(e.to_string()); + return Command::none(); + }; + self.error = None; + self.internal_bitcoind_config = Some(conf.clone()); + return Command::perform(async {}, |_| { + Message::InternalBitcoind(message::InternalBitcoindMsg::Reload) + }); + } + message::InternalBitcoindMsg::Start => { + if let Some(path) = &self.exe_path { + let datadir = match self.bitcoind_datadir.canonicalize() { + Ok(datadir) => datadir, + Err(e) => { + self.started = Some(Err( + StartInternalBitcoindError::CouldNotCanonicalizeDataDir( + e.to_string(), + ), + )); + return Command::none(); + } + }; + let exe_config = InternalBitcoindExeConfig { + exe_path: path.to_path_buf(), + data_dir: datadir, + }; + if let Err(e) = start_internal_bitcoind(&self.network, exe_config.clone()) { + self.started = + Some(Err(StartInternalBitcoindError::CommandError(e.to_string()))); + return Command::none(); + } + // Need to wait for cookie file to appear. + let cookie_path = + internal_bitcoind_cookie_path(&self.bitcoind_datadir, &self.network); + if !poll_for_file(&cookie_path, 200, 15) { + self.started = + Some(Err(StartInternalBitcoindError::CookieFileNotFound( + cookie_path.to_string_lossy().into_owned(), + ))); + return Command::none(); + } + let rpc_port = self + .internal_bitcoind_config + .as_ref() + .expect("Already added") + .clone() + .networks + .get(&self.network) + .expect("Already added") + .rpc_port; + let bitcoind_config = match cookie_path.canonicalize() { + Ok(cookie_path) => BitcoindConfig { + cookie_path, + addr: internal_bitcoind_address(rpc_port), + }, + Err(e) => { + self.started = Some(Err( + StartInternalBitcoindError::CouldNotCanonicalizeCookiePath( + e.to_string(), + ), + )); + return Command::none(); + } + }; + match liana::BitcoinD::new( + &bitcoind_config, + "internal_bitcoind_connection_check".to_string(), + ) { + Ok(_) => { + self.error = None; + self.bitcoind_config = Some(bitcoind_config); + self.exe_config = Some(exe_config); + self.started = Some(Ok(())); + } + Err(e) => { + self.started = Some(Err( + StartInternalBitcoindError::BitcoinDError(e.to_string()), + )); + } + } + } + } + }; + }; + Command::none() + } + + fn load(&self) -> Command { + if self.internal_bitcoind_config.is_none() { + return Command::perform(async {}, |_| { + Message::InternalBitcoind(message::InternalBitcoindMsg::DefineConfig) + }); + } + if self.started.is_none() { + return Command::perform(async {}, |_| { + Message::InternalBitcoind(message::InternalBitcoindMsg::Start) + }); + } + Command::none() + } + + fn apply(&mut self, ctx: &mut Context) -> bool { + // Any errors have been handled as part of `message::InternalBitcoindMsg::Start` + if let Some(Ok(_)) = self.started { + ctx.bitcoind_config = self.bitcoind_config.clone(); + ctx.internal_bitcoind_config = self.internal_bitcoind_config.clone(); + ctx.internal_bitcoind_exe_config = self.exe_config.clone(); + self.error = None; + return true; + } + false + } + + fn view(&self, progress: (usize, usize)) -> Element { + view::start_internal_bitcoind( + progress, + self.exe_path.as_ref(), + self.started.as_ref(), + self.error.as_ref(), + ) + } + + fn stop(&self) { + // In case the installer is closed before changes written to context, stop bitcoind. + if let Some(Ok(_)) = self.started { + if let Some(bitcoind_config) = &self.bitcoind_config { + stop_internal_bitcoind(bitcoind_config); + } + } + } + + fn skip(&self, ctx: &Context) -> bool { + ctx.bitcoind_is_external + } +} + +#[cfg(test)] +mod tests { + use crate::installer::step::bitcoind::{InternalBitcoindConfig, InternalBitcoindNetworkConfig}; + use ini::Ini; + use liana::miniscript::bitcoin::Network; + + // Test the format of the internal bitcoind configuration file. + #[test] + fn internal_bitcoind_config() { + // A valid config + let mut conf_ini = Ini::new(); + conf_ini + .with_section(Some("main")) + .set("rpcport", "43345") + .set("port", "42355") + .set("prune", "15246"); + conf_ini + .with_section(Some("regtest")) + .set("rpcport", "34067") + .set("port", "45175") + .set("prune", "2043"); + let conf = InternalBitcoindConfig::from_ini(&conf_ini).expect("Loading conf from ini"); + let main_conf = InternalBitcoindNetworkConfig { + rpc_port: 43345, + p2p_port: 42355, + prune: 15246, + }; + let regtest_conf = InternalBitcoindNetworkConfig { + rpc_port: 34067, + p2p_port: 45175, + prune: 2043, + }; + assert_eq!(conf.networks.len(), 2); + assert_eq!( + conf.networks.get(&Network::Bitcoin).expect("Missing main"), + &main_conf + ); + assert_eq!( + conf.networks + .get(&Network::Regtest) + .expect("Missing regtest"), + ®test_conf + ); + + let mut conf = InternalBitcoindConfig::new(); + conf.networks.insert(Network::Bitcoin, main_conf); + conf.networks.insert(Network::Regtest, regtest_conf); + for (sec, prop) in &conf.to_ini() { + if let Some(sec) = sec { + assert_eq!(prop.len(), 3); + let rpc_port = prop.get("rpcport").expect("rpcport"); + let p2p_port = prop.get("port").expect("port"); + let prune = prop.get("prune").expect("prune"); + if sec == "main" { + assert_eq!(rpc_port, "43345"); + assert_eq!(p2p_port, "42355"); + assert_eq!(prune, "15246"); + } else if sec == "regtest" { + assert_eq!(rpc_port, "34067"); + assert_eq!(p2p_port, "45175"); + assert_eq!(prune, "2043"); + } else { + panic!("Unexpected section"); + } + } else { + assert!(prop.is_empty()) + } + } + } +} diff --git a/gui/src/installer/step/mod.rs b/gui/src/installer/step/mod.rs index 0450ce1b..4b0f359d 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -1,39 +1,25 @@ +mod bitcoind; mod descriptor; mod mnemonic; +pub use bitcoind::{ + DefineBitcoind, InternalBitcoindConfig, InternalBitcoindStep, SelectBitcoindTypeStep, +}; + pub use descriptor::{ BackupDescriptor, DefineDescriptor, ImportDescriptor, ParticipateXpub, RegisterDescriptor, }; pub use mnemonic::{BackupMnemonic, RecoverMnemonic}; -use std::collections::BTreeMap; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; -use std::path::{Path, PathBuf}; -use std::str::FromStr; +use std::path::PathBuf; use iced::Command; -use liana::{ - config::BitcoindConfig, - miniscript::bitcoin::{bip32::Fingerprint, Network}, -}; +use liana::miniscript::bitcoin::bip32::Fingerprint; -use tracing::info; +use liana_ui::widget::*; -use jsonrpc::{client::Client, simple_http::SimpleHttpTransport}; - -use liana_ui::{component::form, widget::*}; - -use crate::{ - bitcoind::{start_internal_bitcoind, stop_internal_bitcoind, StartInternalBitcoindError}, - installer::{ - context::Context, - internal_bitcoind_datadir, - message::{self, Message}, - view, Error, InternalBitcoindExeConfig, - }, - utils::poll_for_file, -}; +use crate::installer::{context::Context, message::Message, view}; pub trait Step { fn update(&mut self, _message: Message) -> Command { @@ -68,680 +54,6 @@ impl From for Box { } } -pub struct DefineBitcoind { - cookie_path: form::Value, - address: form::Value, - is_running: Option>, -} - -pub struct InternalBitcoindStep { - bitcoind_datadir: PathBuf, - network: Network, - started: Option>, - exe_path: Option, - bitcoind_config: Option, - exe_config: Option, - internal_bitcoind_config: Option, - error: Option, -} - -pub struct SelectBitcoindTypeStep { - use_external: bool, -} - -/// Default prune value used by internal bitcoind. -pub const PRUNE_DEFAULT: u32 = 15_000; -/// Default ports used by bitcoind across all networks. -pub const BITCOIND_DEFAULT_PORTS: [u16; 8] = [8332, 8333, 18332, 18333, 18443, 18444, 38332, 38333]; - -/// Represents section for a single network in `bitcoin.conf` file. -#[derive(PartialEq, Eq, Debug, Clone)] -pub struct InternalBitcoindNetworkConfig { - rpc_port: u16, - p2p_port: u16, - prune: u32, -} - -/// Represents the `bitcoin.conf` file to be used by internal bitcoind. -#[derive(Debug, Clone)] -pub struct InternalBitcoindConfig { - networks: BTreeMap, -} - -#[derive(PartialEq, Eq, Debug, Clone)] -pub enum InternalBitcoindConfigError { - KeyNotFound(String), - CouldNotParseValue(String), - UnexpectedSection(String), - TooManyElements(String), - FileNotFound, - ReadingFile(String), - WritingFile(String), - Unexpected(String), -} - -impl std::fmt::Display for InternalBitcoindConfigError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::KeyNotFound(e) => write!(f, "Config file does not contain expected key: {}", e), - Self::CouldNotParseValue(e) => write!(f, "Value could not be parsed: {}", e), - Self::UnexpectedSection(e) => write!(f, "Unexpected section in file: {}", e), - Self::TooManyElements(section) => { - write!(f, "Section in file contains too many elements: {}", section) - } - Self::FileNotFound => write!(f, "File not found"), - Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e), - Self::WritingFile(e) => write!(f, "Error while writing file: {}", e), - Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), - } - } -} - -impl Default for InternalBitcoindConfig { - fn default() -> Self { - Self::new() - } -} - -impl InternalBitcoindConfig { - pub fn new() -> Self { - Self { - networks: BTreeMap::new(), - } - } - - pub fn from_ini(ini: &ini::Ini) -> Result { - let mut networks = BTreeMap::new(); - for (maybe_sec, prop) in ini { - if let Some(sec) = maybe_sec { - let network = Network::from_core_arg(sec) - .map_err(|e| InternalBitcoindConfigError::UnexpectedSection(e.to_string()))?; - if prop.len() > 3 { - return Err(InternalBitcoindConfigError::TooManyElements( - sec.to_string(), - )); - } - let rpc_port = prop - .get("rpcport") - .ok_or_else(|| InternalBitcoindConfigError::KeyNotFound("rpcport".to_string()))? - .parse::() - .map_err(|e| InternalBitcoindConfigError::CouldNotParseValue(e.to_string()))?; - let p2p_port = prop - .get("port") - .ok_or_else(|| InternalBitcoindConfigError::KeyNotFound("port".to_string()))? - .parse::() - .map_err(|e| InternalBitcoindConfigError::CouldNotParseValue(e.to_string()))?; - let prune = prop - .get("prune") - .ok_or_else(|| InternalBitcoindConfigError::KeyNotFound("prune".to_string()))? - .parse::() - .map_err(|e| InternalBitcoindConfigError::CouldNotParseValue(e.to_string()))?; - networks.insert( - network, - InternalBitcoindNetworkConfig { - rpc_port, - p2p_port, - prune, - }, - ); - } else if !prop.is_empty() { - return Err(InternalBitcoindConfigError::UnexpectedSection( - "General section should be empty".to_string(), - )); - } - } - Ok(Self { networks }) - } - - pub fn from_file(path: &PathBuf) -> Result { - if !path.exists() { - return Err(InternalBitcoindConfigError::FileNotFound); - } - let conf_ini = ini::Ini::load_from_file(path) - .map_err(|e| InternalBitcoindConfigError::ReadingFile(e.to_string()))?; - - Self::from_ini(&conf_ini) - } - - pub fn to_ini(&self) -> ini::Ini { - let mut conf_ini = ini::Ini::new(); - - for (network, network_conf) in &self.networks { - conf_ini - .with_section(Some(network.to_core_arg())) - .set("rpcport", network_conf.rpc_port.to_string()) - .set("port", network_conf.p2p_port.to_string()) - .set("prune", network_conf.prune.to_string()); - } - conf_ini - } - - pub fn to_file(&self, path: &PathBuf) -> Result<(), InternalBitcoindConfigError> { - std::fs::create_dir_all( - path.parent() - .ok_or_else(|| InternalBitcoindConfigError::Unexpected("No parent".to_string()))?, - ) - .map_err(|e| InternalBitcoindConfigError::Unexpected(e.to_string()))?; - info!("Writing to file {}", path.to_string_lossy()); - self.to_ini() - .write_to_file(path) - .map_err(|e| InternalBitcoindConfigError::WritingFile(e.to_string()))?; - - Ok(()) - } -} - -/// Path of the `bitcoin.conf` file used by internal bitcoind. -fn internal_bitcoind_config_path(bitcoind_datadir: &PathBuf) -> PathBuf { - let mut config_path = PathBuf::from(bitcoind_datadir); - config_path.push("bitcoin.conf"); - config_path -} - -/// Path of the cookie file used by internal bitcoind on a given network. -fn internal_bitcoind_cookie_path(bitcoind_datadir: &Path, network: &Network) -> PathBuf { - let mut cookie_path = bitcoind_datadir.to_path_buf(); - if let Some(dir) = bitcoind_network_dir(network) { - cookie_path.push(dir); - } - cookie_path.push(".cookie"); - cookie_path -} - -/// RPC address for internal bitcoind. -fn internal_bitcoind_address(rpc_port: u16) -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), rpc_port) -} - -fn bitcoind_default_datadir() -> Option { - #[cfg(target_os = "linux")] - let configs_dir = dirs::home_dir(); - - #[cfg(not(target_os = "linux"))] - let configs_dir = dirs::config_dir(); - - if let Some(mut path) = configs_dir { - #[cfg(target_os = "linux")] - path.push(".bitcoin"); - - #[cfg(not(target_os = "linux"))] - path.push("Bitcoin"); - - return Some(path); - } - None -} - -fn bitcoind_network_dir(network: &Network) -> Option { - let dir = match network { - Network::Bitcoin => { - return None; - } - Network::Testnet => "testnet3", - Network::Regtest => "regtest", - Network::Signet => "signet", - _ => panic!("Directory required for this network is unknown."), - }; - Some(dir.to_string()) -} - -fn bitcoind_default_cookie_path(network: &Network) -> Option { - if let Some(mut path) = bitcoind_default_datadir() { - if let Some(dir) = bitcoind_network_dir(network) { - path.push(dir); - } - path.push(".cookie"); - return path.to_str().map(|s| s.to_string()); - } - None -} - -fn bitcoind_default_address(network: &Network) -> String { - match network { - Network::Bitcoin => "127.0.0.1:8332".to_string(), - Network::Testnet => "127.0.0.1:18332".to_string(), - Network::Regtest => "127.0.0.1:18443".to_string(), - Network::Signet => "127.0.0.1:38332".to_string(), - _ => "127.0.0.1:8332".to_string(), - } -} - -/// Looks for bitcoind executable path and returns `None` if not found. -fn bitcoind_exe_path() -> Option { - which::which("bitcoind").ok() -} - -/// Get available port that is valid for use by internal bitcoind. -// Modified from https://github.com/RCasatta/bitcoind/blob/f047740d7d0af935ff7360cf77429c5f294cfd59/src/lib.rs#L435 -pub fn get_available_port() -> Result { - // Perform multiple attempts to get a valid port. - for _ in 0..10 { - // Using 0 as port lets the system assign a port available. - let t = TcpListener::bind(("127.0.0.1", 0)) - .map_err(|e| Error::CannotGetAvailablePort(e.to_string()))?; - let port = t - .local_addr() - .map(|s| s.port()) - .map_err(|e| Error::CannotGetAvailablePort(e.to_string()))?; - if port_is_valid(&port) { - return Ok(port); - } - } - Err(Error::CannotGetAvailablePort( - "Exhausted attempts".to_string(), - )) -} - -/// Checks if port is valid for use by internal bitcoind. -pub fn port_is_valid(port: &u16) -> bool { - !BITCOIND_DEFAULT_PORTS.contains(port) -} - -impl Default for SelectBitcoindTypeStep { - fn default() -> Self { - Self::new() - } -} - -impl From for Box { - fn from(s: SelectBitcoindTypeStep) -> Box { - Box::new(s) - } -} - -impl SelectBitcoindTypeStep { - pub fn new() -> Self { - Self { use_external: true } - } -} - -impl Step for SelectBitcoindTypeStep { - fn update(&mut self, message: Message) -> Command { - if let Message::SelectBitcoindType(msg) = message { - match msg { - message::SelectBitcoindTypeMsg::UseExternal(selected) => { - self.use_external = selected; - } - }; - return Command::perform(async {}, |_| Message::Next); - }; - Command::none() - } - - fn apply(&mut self, ctx: &mut Context) -> bool { - if !self.use_external { - if ctx.internal_bitcoind_config.is_none() { - ctx.bitcoind_config = None; // Ensures internal bitcoind can be restarted in case user has switched selection. - } - } else { - ctx.internal_bitcoind_config = None; - ctx.internal_bitcoind_exe_config = None; - } - ctx.bitcoind_is_external = self.use_external; - true - } - - fn view(&self, progress: (usize, usize)) -> Element { - view::select_bitcoind_type(progress) - } -} - -impl DefineBitcoind { - pub fn new() -> Self { - Self { - cookie_path: form::Value::default(), - address: form::Value::default(), - is_running: None, - } - } - - pub fn ping(&self) -> Command { - let address = self.address.value.to_owned(); - let cookie_path = self.cookie_path.value.to_owned(); - Command::perform( - async move { - let cookie = std::fs::read_to_string(&cookie_path) - .map_err(|e| Error::Bitcoind(format!("Failed to read cookie file: {}", e)))?; - let client = Client::with_transport( - SimpleHttpTransport::builder() - .url(&address)? - .timeout(std::time::Duration::from_secs(3)) - .cookie_auth(cookie) - .build(), - ); - client.send_request(client.build_request("echo", &[]))?; - Ok(()) - }, - |res| Message::DefineBitcoind(message::DefineBitcoind::PingBitcoindResult(res)), - ) - } -} - -impl Step for DefineBitcoind { - fn load_context(&mut self, ctx: &Context) { - if self.cookie_path.value.is_empty() { - self.cookie_path.value = - bitcoind_default_cookie_path(&ctx.bitcoin_config.network).unwrap_or_default() - } - if self.address.value.is_empty() { - self.address.value = bitcoind_default_address(&ctx.bitcoin_config.network); - } - } - fn update(&mut self, message: Message) -> Command { - if let Message::DefineBitcoind(msg) = message { - match msg { - message::DefineBitcoind::PingBitcoind => { - self.is_running = None; - return self.ping(); - } - message::DefineBitcoind::PingBitcoindResult(res) => self.is_running = Some(res), - message::DefineBitcoind::AddressEdited(address) => { - self.is_running = None; - self.address.value = address; - self.address.valid = true; - } - message::DefineBitcoind::CookiePathEdited(path) => { - self.is_running = None; - self.cookie_path.value = path; - self.address.valid = true; - } - }; - }; - Command::none() - } - - fn apply(&mut self, ctx: &mut Context) -> bool { - match ( - PathBuf::from_str(&self.cookie_path.value), - std::net::SocketAddr::from_str(&self.address.value), - ) { - (Err(_), Ok(_)) => { - self.cookie_path.valid = false; - false - } - (Ok(_), Err(_)) => { - self.address.valid = false; - false - } - (Err(_), Err(_)) => { - self.cookie_path.valid = false; - self.address.valid = false; - false - } - (Ok(path), Ok(addr)) => { - ctx.bitcoind_config = Some(BitcoindConfig { - cookie_path: path, - addr, - }); - true - } - } - } - - fn view(&self, progress: (usize, usize)) -> Element { - view::define_bitcoin( - progress, - &self.address, - &self.cookie_path, - self.is_running.as_ref(), - ) - } - - fn load(&self) -> Command { - self.ping() - } - - fn skip(&self, ctx: &Context) -> bool { - !ctx.bitcoind_is_external - } -} - -impl Default for DefineBitcoind { - fn default() -> Self { - Self::new() - } -} - -impl From for Box { - fn from(s: DefineBitcoind) -> Box { - Box::new(s) - } -} - -impl From for Box { - fn from(s: InternalBitcoindStep) -> Box { - Box::new(s) - } -} - -impl InternalBitcoindStep { - pub fn new(liana_datadir: &PathBuf) -> Self { - Self { - bitcoind_datadir: internal_bitcoind_datadir(liana_datadir), - network: Network::Bitcoin, - started: None, - exe_path: None, - bitcoind_config: None, - exe_config: None, - internal_bitcoind_config: None, - error: None, - } - } -} - -impl Step for InternalBitcoindStep { - fn load_context(&mut self, ctx: &Context) { - if self.exe_path.is_none() { - self.exe_path = if let Some(exe_config) = ctx.internal_bitcoind_exe_config.clone() { - Some(exe_config.exe_path) - } else { - bitcoind_exe_path() - }; - } - self.network = ctx.bitcoin_config.network; - if let Some(Ok(_)) = self.started { - // This case can arise if a user switches from internal bitcoind to external and back to internal. - if ctx.bitcoind_config.is_none() { - self.started = None; // So that internal bitcoind will be restarted. - } - } - } - fn update(&mut self, message: Message) -> Command { - if let Message::InternalBitcoind(msg) = message { - match msg { - message::InternalBitcoindMsg::Previous => { - if self.internal_bitcoind_config.is_some() { - if let Some(bitcoind_config) = &self.bitcoind_config { - stop_internal_bitcoind(bitcoind_config); - } - } - return Command::perform(async {}, |_| Message::Previous); - } - message::InternalBitcoindMsg::Reload => { - return self.load(); - } - message::InternalBitcoindMsg::DefineConfig => { - let mut conf = match InternalBitcoindConfig::from_file( - &internal_bitcoind_config_path(&self.bitcoind_datadir), - ) { - Ok(conf) => conf, - Err(InternalBitcoindConfigError::FileNotFound) => { - InternalBitcoindConfig::new() - } - Err(e) => { - self.error = Some(e.to_string()); - return Command::none(); - } - }; - // Insert entry for network if not present. - if conf.networks.get(&self.network).is_none() { - let network_conf = match (get_available_port(), get_available_port()) { - (Ok(rpc_port), Ok(p2p_port)) => { - // In case ports are the same, user will need to click button again for another attempt. - if rpc_port == p2p_port { - self.error = Some( - "Could not get distinct ports. Please try again." - .to_string(), - ); - return Command::none(); - } - InternalBitcoindNetworkConfig { - rpc_port, - p2p_port, - prune: PRUNE_DEFAULT, - } - } - (Ok(_), Err(e)) | (Err(e), Ok(_)) => { - self.error = Some(format!("Could not get available port: {}.", e)); - return Command::none(); - } - (Err(e1), Err(e2)) => { - self.error = - Some(format!("Could not get available ports: {}; {}.", e1, e2)); - return Command::none(); - } - }; - conf.networks.insert(self.network, network_conf); - } - if let Err(e) = - conf.to_file(&internal_bitcoind_config_path(&self.bitcoind_datadir)) - { - self.error = Some(e.to_string()); - return Command::none(); - }; - self.error = None; - self.internal_bitcoind_config = Some(conf.clone()); - return Command::perform(async {}, |_| { - Message::InternalBitcoind(message::InternalBitcoindMsg::Reload) - }); - } - message::InternalBitcoindMsg::Start => { - if let Some(path) = &self.exe_path { - let datadir = match self.bitcoind_datadir.canonicalize() { - Ok(datadir) => datadir, - Err(e) => { - self.started = Some(Err( - StartInternalBitcoindError::CouldNotCanonicalizeDataDir( - e.to_string(), - ), - )); - return Command::none(); - } - }; - let exe_config = InternalBitcoindExeConfig { - exe_path: path.to_path_buf(), - data_dir: datadir, - }; - if let Err(e) = start_internal_bitcoind(&self.network, exe_config.clone()) { - self.started = - Some(Err(StartInternalBitcoindError::CommandError(e.to_string()))); - return Command::none(); - } - // Need to wait for cookie file to appear. - let cookie_path = - internal_bitcoind_cookie_path(&self.bitcoind_datadir, &self.network); - if !poll_for_file(&cookie_path, 200, 15) { - self.started = - Some(Err(StartInternalBitcoindError::CookieFileNotFound( - cookie_path.to_string_lossy().into_owned(), - ))); - return Command::none(); - } - let rpc_port = self - .internal_bitcoind_config - .as_ref() - .expect("Already added") - .clone() - .networks - .get(&self.network) - .expect("Already added") - .rpc_port; - let bitcoind_config = match cookie_path.canonicalize() { - Ok(cookie_path) => BitcoindConfig { - cookie_path, - addr: internal_bitcoind_address(rpc_port), - }, - Err(e) => { - self.started = Some(Err( - StartInternalBitcoindError::CouldNotCanonicalizeCookiePath( - e.to_string(), - ), - )); - return Command::none(); - } - }; - match liana::BitcoinD::new( - &bitcoind_config, - "internal_bitcoind_connection_check".to_string(), - ) { - Ok(_) => { - self.error = None; - self.bitcoind_config = Some(bitcoind_config); - self.exe_config = Some(exe_config); - self.started = Some(Ok(())); - } - Err(e) => { - self.started = Some(Err( - StartInternalBitcoindError::BitcoinDError(e.to_string()), - )); - } - } - } - } - }; - }; - Command::none() - } - - fn load(&self) -> Command { - if self.internal_bitcoind_config.is_none() { - return Command::perform(async {}, |_| { - Message::InternalBitcoind(message::InternalBitcoindMsg::DefineConfig) - }); - } - if self.started.is_none() { - return Command::perform(async {}, |_| { - Message::InternalBitcoind(message::InternalBitcoindMsg::Start) - }); - } - Command::none() - } - - fn apply(&mut self, ctx: &mut Context) -> bool { - // Any errors have been handled as part of `message::InternalBitcoindMsg::Start` - if let Some(Ok(_)) = self.started { - ctx.bitcoind_config = self.bitcoind_config.clone(); - ctx.internal_bitcoind_config = self.internal_bitcoind_config.clone(); - ctx.internal_bitcoind_exe_config = self.exe_config.clone(); - self.error = None; - return true; - } - false - } - - fn view(&self, progress: (usize, usize)) -> Element { - view::start_internal_bitcoind( - progress, - self.exe_path.as_ref(), - self.started.as_ref(), - self.error.as_ref(), - ) - } - - fn stop(&self) { - // In case the installer is closed before changes written to context, stop bitcoind. - if let Some(Ok(_)) = self.started { - if let Some(bitcoind_config) = &self.bitcoind_config { - stop_internal_bitcoind(bitcoind_config); - } - } - } - - fn skip(&self, ctx: &Context) -> bool { - ctx.bitcoind_is_external - } -} - pub struct Final { generating: bool, context: Option, @@ -828,74 +140,3 @@ impl From for Box { Box::new(s) } } - -#[cfg(test)] -mod tests { - use crate::installer::step::{InternalBitcoindConfig, InternalBitcoindNetworkConfig}; - use ini::Ini; - use liana::miniscript::bitcoin::Network; - - // Test the format of the internal bitcoind configuration file. - #[test] - fn internal_bitcoind_config() { - // A valid config - let mut conf_ini = Ini::new(); - conf_ini - .with_section(Some("main")) - .set("rpcport", "43345") - .set("port", "42355") - .set("prune", "15246"); - conf_ini - .with_section(Some("regtest")) - .set("rpcport", "34067") - .set("port", "45175") - .set("prune", "2043"); - let conf = InternalBitcoindConfig::from_ini(&conf_ini).expect("Loading conf from ini"); - let main_conf = InternalBitcoindNetworkConfig { - rpc_port: 43345, - p2p_port: 42355, - prune: 15246, - }; - let regtest_conf = InternalBitcoindNetworkConfig { - rpc_port: 34067, - p2p_port: 45175, - prune: 2043, - }; - assert_eq!(conf.networks.len(), 2); - assert_eq!( - conf.networks.get(&Network::Bitcoin).expect("Missing main"), - &main_conf - ); - assert_eq!( - conf.networks - .get(&Network::Regtest) - .expect("Missing regtest"), - ®test_conf - ); - - let mut conf = InternalBitcoindConfig::new(); - conf.networks.insert(Network::Bitcoin, main_conf); - conf.networks.insert(Network::Regtest, regtest_conf); - for (sec, prop) in &conf.to_ini() { - if let Some(sec) = sec { - assert_eq!(prop.len(), 3); - let rpc_port = prop.get("rpcport").expect("rpcport"); - let p2p_port = prop.get("port").expect("port"); - let prune = prop.get("prune").expect("prune"); - if sec == "main" { - assert_eq!(rpc_port, "43345"); - assert_eq!(p2p_port, "42355"); - assert_eq!(prune, "15246"); - } else if sec == "regtest" { - assert_eq!(rpc_port, "34067"); - assert_eq!(p2p_port, "45175"); - assert_eq!(prune, "2043"); - } else { - panic!("Unexpected section"); - } - } else { - assert!(prop.is_empty()) - } - } - } -}