diff --git a/Cargo.lock b/Cargo.lock index a771737..40ede6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1736,6 +1736,7 @@ dependencies = [ "base64 0.21.7", "bytes", "chrono", + "clap", "futures-util", "hex-conservative 0.2.1", "http-body-util", diff --git a/README.md b/README.md index e3358c4..b10b3ad 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ We welcome your feedback and contributions to help shape the future of LDK Serve ### Configuration Refer `./ldk-server/ldk-server-config.toml` to see available configuration options. +You can configure the node via a TOML file, environment variables, or CLI arguments. All options are optional — values provided via CLI override environment variables, which override the values in the TOML file. + ### Building ``` git clone https://github.com/lightningdevkit/ldk-server.git @@ -45,11 +47,25 @@ cargo build ``` ### Running +- Using a config file: ``` cargo run --bin ldk-server ./ldk-server/ldk-server-config.toml ``` -Interact with the node using CLI: +- Using environment variables (all optional): +``` +export LDK_SERVER_NODE_NETWORK=regtest +export LDK_SERVER_NODE_LISTENING_ADDRESS=localhost:3001 +export LDK_SERVER_NODE_REST_SERVICE_ADDRESS=127.0.0.1:3002 +export LDK_SERVER_NODE_ALIAS=LDK-Server +export LDK_SERVER_BITCOIND_RPC_ADDRESS=127.0.0.1:18443 +export LDK_SERVER_BITCOIND_RPC_USER=your-rpc-user +export LDK_SERVER_BITCOIND_RPC_PASSWORD=your-rpc-password +export LDK_SERVER_STORAGE_DIR_PATH=/path/to/storage +cargo run --bin ldk-server +``` + +- Using CLI arguments (all optional): ``` ./target/debug/ldk-server-cli -b localhost:3002 --api-key your-secret-api-key onchain-receive # To generate onchain-receive address. ./target/debug/ldk-server-cli -b localhost:3002 --api-key your-secret-api-key help # To print help/available commands. diff --git a/ldk-server/Cargo.toml b/ldk-server/Cargo.toml index 965c960..77d2cb9 100644 --- a/ldk-server/Cargo.toml +++ b/ldk-server/Cargo.toml @@ -23,6 +23,7 @@ toml = { version = "0.8.9", default-features = false, features = ["parse"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } log = "0.4.28" base64 = { version = "0.21", default-features = false, features = ["std"] } +clap = { version = "4.0.5", default-features = false, features = ["derive", "std", "error-context", "suggestions", "help", "env"] } # Required for RabittMQ based EventPublisher. Only enabled for `events-rabbitmq` feature. lapin = { version = "2.4.0", features = ["rustls"], default-features = false, optional = true } diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 13cfb51..6c39e6d 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -23,6 +23,8 @@ use tokio::signal::unix::SignalKind; use hyper::server::conn::http1; use hyper_util::rt::TokioIo; +use clap::Parser; + use crate::io::events::event_publisher::EventPublisher; use crate::io::events::get_event_name; #[cfg(feature = "events-rabbitmq")] @@ -34,7 +36,7 @@ use crate::io::persist::{ FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, }; -use crate::util::config::{load_config, ChainSource}; +use crate::util::config::{load_config, ArgsConfig, ChainSource}; use crate::util::logger::ServerLogger; use crate::util::proto_adapter::{forwarded_payment_to_proto, payment_to_proto}; use crate::util::tls::get_or_generate_tls_config; @@ -47,38 +49,19 @@ use ldk_server_protos::types::Payment; use log::{error, info}; use prost::Message; use rand::Rng; -use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::select; -const USAGE_GUIDE: &str = "Usage: ldk-server "; - fn main() { - let args: Vec = std::env::args().collect(); - - if args.len() < 2 { - eprintln!("{USAGE_GUIDE}"); - std::process::exit(-1); - } - - let arg = args[1].as_str(); - if arg == "-h" || arg == "--help" { - println!("{}", USAGE_GUIDE); - std::process::exit(0); - } - - if fs::File::open(arg).is_err() { - eprintln!("Unable to access configuration file."); - std::process::exit(-1); - } + let args_config = ArgsConfig::parse(); let mut ldk_node_config = Config::default(); - let config_file = match load_config(Path::new(arg)) { + let config_file = match load_config(&args_config) { Ok(config) => config, Err(e) => { - eprintln!("Invalid configuration file: {}", e); + eprintln!("Invalid configuration: {}", e); std::process::exit(-1); }, }; diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index 2128f78..dcb1e9d 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -7,6 +7,7 @@ // You may not use this file except in accordance with one or both of these // licenses. +use clap::Parser; use ldk_node::bitcoin::Network; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning::routing::gossip::NodeAlias; @@ -14,7 +15,6 @@ use ldk_node::liquidity::LSPS2ServiceConfig; use log::LevelFilter; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; -use std::path::Path; use std::str::FromStr; use std::{fs, io}; @@ -43,79 +43,206 @@ pub struct TlsConfig { pub hosts: Vec, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum ChainSource { Rpc { rpc_address: SocketAddr, rpc_user: String, rpc_password: String }, Electrum { server_url: String }, Esplora { server_url: String }, } -impl TryFrom for Config { - type Error = io::Error; +/// A builder for `Config`. +#[derive(Default)] +struct ConfigBuilder { + listening_address: Option, + alias: Option, + network: Option, + api_key: Option, + tls_config: Option, + rest_service_address: Option, + storage_dir_path: Option, + electrum_url: Option, + esplora_url: Option, + bitcoind_rpc_addr: Option, + bitcoind_rpc_user: Option, + bitcoind_rpc_password: Option, + rabbitmq_connection_string: Option, + rabbitmq_exchange_name: Option, + lsps2: Option, + log_level: Option, + log_file_path: Option, +} - fn try_from(toml_config: TomlConfig) -> io::Result { - let listening_addr = - SocketAddress::from_str(&toml_config.node.listening_address).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("Invalid listening address configured: {}", e), - ) - })?; - let rest_service_addr = SocketAddr::from_str(&toml_config.node.rest_service_address) - .map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("Invalid rest service address configured: {}", e), - ) - })?; - let chain_source = match (toml_config.esplora, toml_config.electrum, toml_config.bitcoind) { - (Some(EsploraConfig { server_url }), None, None) => ChainSource::Esplora { server_url }, - (None, Some(ElectrumConfig { server_url }), None) => { - ChainSource::Electrum { server_url } - }, - (None, None, Some(BitcoindConfig { rpc_address, rpc_user, rpc_password })) => { - let rpc_address = SocketAddr::from_str(&rpc_address).map_err(|e| { - io::Error::new( +impl ConfigBuilder { + fn merge_toml(&mut self, toml: TomlConfig) { + if let Some(node) = toml.node { + self.network = node.network.or(self.network); + self.listening_address = node.listening_address.or(self.listening_address.clone()); + self.rest_service_address = + node.rest_service_address.or(self.rest_service_address.clone()); + self.alias = node.alias.or(self.alias.clone()); + self.api_key = node.api_key.or(self.api_key.clone()); + } + + if let Some(storage) = toml.storage { + self.storage_dir_path = storage.disk.dir_path.or(self.storage_dir_path.clone()); + } + + if let Some(bitcoind) = toml.bitcoind { + self.bitcoind_rpc_addr = bitcoind.rpc_address.or(self.bitcoind_rpc_addr.clone()); + self.bitcoind_rpc_user = bitcoind.rpc_user.or(self.bitcoind_rpc_user.clone()); + self.bitcoind_rpc_password = + bitcoind.rpc_password.or(self.bitcoind_rpc_password.clone()); + } + + if let Some(electrum) = toml.electrum { + self.electrum_url = Some(electrum.server_url); + } + + if let Some(esplora) = toml.esplora { + self.esplora_url = Some(esplora.server_url); + } + + if let Some(log) = toml.log { + self.log_level = log.level.or(self.log_level.clone()); + self.log_file_path = log.file.or(self.log_file_path.clone()); + } + + if let Some(rabbitmq) = toml.rabbitmq { + self.rabbitmq_connection_string = Some(rabbitmq.connection_string); + self.rabbitmq_exchange_name = Some(rabbitmq.exchange_name); + } + + if let Some(liquidity) = toml.liquidity { + self.lsps2 = Some(liquidity); + } + + if let Some(tls) = toml.tls { + self.tls_config = Some(TlsConfig { + cert_path: tls.cert_path, + key_path: tls.key_path, + hosts: tls.hosts.unwrap_or_default(), + }); + } + } + + fn merge_args(&mut self, args: &ArgsConfig) { + if let Some(network) = args.node_network { + self.network = Some(network); + } + + if let Some(node_listening_address) = &args.node_listening_address { + self.listening_address = Some(node_listening_address.clone()); + } + + if let Some(node_rest_service_address) = &args.node_rest_service_address { + self.rest_service_address = Some(node_rest_service_address.clone()); + } + + if let Some(node_alias) = &args.node_alias { + self.alias = Some(node_alias.clone()); + } + + if let Some(bitcoind_rpc_address) = &args.bitcoind_rpc_address { + self.bitcoind_rpc_addr = Some(bitcoind_rpc_address.clone()); + } + + if let Some(bitcoind_rpc_user) = &args.bitcoind_rpc_user { + self.bitcoind_rpc_user = Some(bitcoind_rpc_user.clone()); + } + + if let Some(bitcoind_rpc_password) = &args.bitcoind_rpc_password { + self.bitcoind_rpc_password = Some(bitcoind_rpc_password.clone()); + } + + if let Some(storage_dir_path) = &args.storage_dir_path { + self.storage_dir_path = Some(storage_dir_path.clone()); + } + + if let Some(api_key) = &args.api_key { + self.api_key = Some(api_key.clone()); + } + } + + fn build(self) -> io::Result { + let network = self.network.ok_or_else(|| missing_field_err("network"))?; + + let rest_service_addr = self + .rest_service_address + .ok_or_else(|| missing_field_err("rest_service_address"))? + .parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + + let storage_dir_path = + self.storage_dir_path.ok_or_else(|| missing_field_err("storage_dir_path"))?; + + let listening_addr = self + .listening_address + .ok_or_else(|| missing_field_err("node_listening_address"))? + .parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + + let alias = self + .alias + .map(|alias_str| { + let mut bytes = [0u8; 32]; + let alias_bytes = alias_str.trim().as_bytes(); + if alias_bytes.len() > 32 { + return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!("Invalid bitcoind RPC address configured: {}", e), - ) + "node.alias must be at most 32 bytes long.".to_string(), + )); + } + bytes[..alias_bytes.len()].copy_from_slice(alias_bytes); + Ok(NodeAlias(bytes)) + }) + .transpose()?; + + let rpc_configured = self.bitcoind_rpc_addr.is_some() + || self.bitcoind_rpc_user.is_some() + || self.bitcoind_rpc_password.is_some(); + let electrum_configured = self.electrum_url.is_some(); + let esplora_configured = self.esplora_url.is_some(); + + let configured_sources_count = [rpc_configured, electrum_configured, esplora_configured] + .iter() + .filter(|&&is_configured| is_configured) + .count(); + + if configured_sources_count > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Must set a single chain source, multiple were configured".to_string(), + )); + } + + let chain_source = if rpc_configured { + let rpc_address = self + .bitcoind_rpc_addr + .ok_or_else(|| missing_field_err("bitcoind_rpc_address"))? + .parse::() + .map_err(|e| { + io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid RPC addr: {}", e)) })?; - ChainSource::Rpc { rpc_address, rpc_user, rpc_password } - }, - (None, None, None) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "At least one chain source must be set, either esplora, electrum, or bitcoind" - .to_string(), - )) - }, - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Must set a single chain source, multiple were configured".to_string(), - )) - }, - }; - let alias = if let Some(alias_str) = toml_config.node.alias { - let mut bytes = [0u8; 32]; - let alias_bytes = alias_str.trim().as_bytes(); - if alias_bytes.len() > 32 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "node.alias must be at most 32 bytes long.".to_string(), - )); - } - bytes[..alias_bytes.len()].copy_from_slice(alias_bytes); - Some(NodeAlias(bytes)) + let rpc_user = + self.bitcoind_rpc_user.ok_or_else(|| missing_field_err("bitcoind_rpc_user"))?; + + let rpc_password = self + .bitcoind_rpc_password + .ok_or_else(|| missing_field_err("bitcoind_rpc_password"))?; + + ChainSource::Rpc { rpc_address, rpc_user, rpc_password } + } else if let Some(url) = self.electrum_url { + ChainSource::Electrum { server_url: url } + } else if let Some(url) = self.esplora_url { + ChainSource::Esplora { server_url: url } } else { - None + return Err(io::Error::new(io::ErrorKind::InvalidInput, "No valid Chain Source configured. Provide Bitcoind RPC, Electrum, or Esplora details.")); }; - let log_level = toml_config - .log + let log_level = self + .log_level .as_ref() - .and_then(|log_config| log_config.level.as_ref()) .map(|level_str| { LevelFilter::from_str(level_str).map_err(|e| { io::Error::new( @@ -127,52 +254,62 @@ impl TryFrom for Config { .transpose()? .unwrap_or(LevelFilter::Debug); + #[cfg(feature = "events-rabbitmq")] let (rabbitmq_connection_string, rabbitmq_exchange_name) = { - let rabbitmq = toml_config.rabbitmq.unwrap_or(RabbitmqConfig { - connection_string: String::new(), - exchange_name: String::new(), - }); - #[cfg(feature = "events-rabbitmq")] - if rabbitmq.connection_string.is_empty() || rabbitmq.exchange_name.is_empty() { + let connection_string = self.rabbitmq_connection_string.ok_or_else(|| io::Error::new( + io::ErrorKind::InvalidInput, + "Both `rabbitmq.connection_string` and `rabbitmq.exchange_name` must be configured if enabling `events-rabbitmq` feature." + ))?; + let exchange_name = self.rabbitmq_exchange_name.ok_or_else(|| io::Error::new( + io::ErrorKind::InvalidInput, + "Both `rabbitmq.connection_string` and `rabbitmq.exchange_name` must be configured if enabling `events-rabbitmq` feature." + ))?; + + if connection_string.is_empty() || exchange_name.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidInput, - "Both `rabbitmq.connection_string` and `rabbitmq.exchange_name` must be configured if enabling `events-rabbitmq` feature.".to_string(), + "Both `rabbitmq.connection_string` and `rabbitmq.exchange_name` must be configured if enabling `events-rabbitmq` feature." )); } - (rabbitmq.connection_string, rabbitmq.exchange_name) + + (connection_string, exchange_name) }; - #[cfg(not(feature = "experimental-lsps2-support"))] - let lsps2_service_config: Option = None; + #[cfg(not(feature = "events-rabbitmq"))] + let (rabbitmq_connection_string, rabbitmq_exchange_name) = (String::new(), String::new()); + #[cfg(feature = "experimental-lsps2-support")] - let lsps2_service_config = Some(toml_config.liquidity - .and_then(|l| l.lsps2_service) - .ok_or_else(|| io::Error::new( + let lsps2_service_config = { + let liquidity = self.lsps2.ok_or_else(|| io::Error::new( io::ErrorKind::InvalidInput, "`liquidity.lsps2_service` must be defined in config if enabling `experimental-lsps2-support` feature." - ))? - .into()); + ))?; + let lsps2_service = liquidity.lsps2_service.ok_or_else(|| io::Error::new( + io::ErrorKind::InvalidInput, + "`liquidity.lsps2_service` must be defined in config if enabling `experimental-lsps2-support` feature." + ))?; + Some(lsps2_service.into()) + }; + + #[cfg(not(feature = "experimental-lsps2-support"))] + let lsps2_service_config = None; - let tls_config = toml_config.tls.map(|tls| TlsConfig { - cert_path: tls.cert_path, - key_path: tls.key_path, - hosts: tls.hosts.unwrap_or_default(), - }); + let api_key = self.api_key.ok_or_else(|| missing_field_err("api_key"))?; Ok(Config { + network, listening_addr, - network: toml_config.node.network, alias, + api_key, + tls_config: self.tls_config, rest_service_addr, - api_key: toml_config.node.api_key, - storage_dir_path: toml_config.storage.disk.dir_path, + storage_dir_path, chain_source, rabbitmq_connection_string, rabbitmq_exchange_name, lsps2_service_config, log_level, - log_file_path: toml_config.log.and_then(|l| l.file), - tls_config, + log_file_path: self.log_file_path, }) } } @@ -180,8 +317,8 @@ impl TryFrom for Config { /// Configuration loaded from a TOML file. #[derive(Deserialize, Serialize)] pub struct TomlConfig { - node: NodeConfig, - storage: StorageConfig, + node: Option, + storage: Option, bitcoind: Option, electrum: Option, esplora: Option, @@ -193,11 +330,11 @@ pub struct TomlConfig { #[derive(Deserialize, Serialize)] struct NodeConfig { - network: Network, - listening_address: String, - rest_service_address: String, + network: Option, + listening_address: Option, + rest_service_address: Option, alias: Option, - api_key: String, + api_key: Option, } #[derive(Deserialize, Serialize)] @@ -207,14 +344,14 @@ struct StorageConfig { #[derive(Deserialize, Serialize)] struct DiskConfig { - dir_path: String, + dir_path: Option, } #[derive(Deserialize, Serialize)] struct BitcoindConfig { - rpc_address: String, - rpc_user: String, - rpc_password: String, + rpc_address: Option, + rpc_user: Option, + rpc_password: Option, } #[derive(Deserialize, Serialize)] @@ -295,84 +432,191 @@ impl From for LSPS2ServiceConfig { } } -/// Loads the configuration from a TOML file at the given path. -pub fn load_config>(config_path: P) -> io::Result { - let file_contents = fs::read_to_string(config_path.as_ref()).map_err(|e| { - io::Error::new( - e.kind(), - format!("Failed to read config file '{}': {}", config_path.as_ref().display(), e), - ) - })?; +#[derive(Parser, Debug)] +#[command(version, about = "LDK Server Configuration", long_about = None)] +pub struct ArgsConfig { + #[arg(required = false)] + config_file: Option, - let toml_config: TomlConfig = toml::from_str(&file_contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("Config file contains invalid TOML format: {}", e), - ) - })?; - Config::try_from(toml_config) + #[arg(long, env = "LDK_SERVER_NODE_NETWORK")] + node_network: Option, + + #[arg(long, env = "LDK_SERVER_NODE_LISTENING_ADDRESS")] + node_listening_address: Option, + + #[arg(long, env = "LDK_SERVER_NODE_REST_SERVICE_ADDRESS")] + node_rest_service_address: Option, + + #[arg(long, env = "LDK_SERVER_NODE_ALIAS")] + node_alias: Option, + + #[arg(long, env = "LDK_SERVER_BITCOIND_RPC_ADDRESS")] + bitcoind_rpc_address: Option, + + #[arg(long, env = "LDK_SERVER_BITCOIND_RPC_USER")] + bitcoind_rpc_user: Option, + + #[arg(long, env = "LDK_SERVER_BITCOIND_RPC_PASSWORD")] + bitcoind_rpc_password: Option, + + #[arg(long, env = "LDK_SERVER_STORAGE_DIR_PATH")] + storage_dir_path: Option, + + #[arg(long, env = "LDK_SERVER_API_KEY")] + api_key: Option, +} + +pub fn load_config(args: &ArgsConfig) -> io::Result { + let mut builder = ConfigBuilder::default(); + + if let Some(path) = &args.config_file { + let content = fs::read_to_string(path).map_err(|e| { + io::Error::new(e.kind(), format!("Failed to read config file '{}': {}", path, e)) + })?; + let toml_config: TomlConfig = toml::from_str(&content).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Config file contains invalid TOML format: {}", e), + ) + })?; + + builder.merge_toml(toml_config); + } else { + #[cfg(any(feature = "events-rabbitmq", feature = "experimental-lsps2-support"))] + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "To use the `{}` feature, you must provide a configuration file.", + if cfg!(feature = "events-rabbitmq") { + "events-rabbitmq" + } else { + "experimental-lsps2-support" + } + ), + )); + } + + builder.merge_args(args); + + builder.build() +} + +fn missing_field_err(field: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Missing `{}`. Please provide it via config file, CLI argument, or environment variable.", + field + ), + ) } #[cfg(test)] mod tests { use super::*; use ldk_node::{bitcoin::Network, lightning::ln::msgs::SocketAddress}; - use std::str::FromStr; - - #[test] - fn test_read_toml_config_from_file() { - let storage_path = std::env::temp_dir(); - let config_file_name = "config.toml"; - let toml_config = r#" - [node] - network = "regtest" - listening_address = "localhost:3001" - rest_service_address = "127.0.0.1:3002" - alias = "LDK Server" - api_key = "test_api_key" + use crate::util::config::{load_config, ArgsConfig}; + use std::str::FromStr; + const DEFAULT_CONFIG: &str = r#" + [node] + network = "regtest" + listening_address = "localhost:3001" + rest_service_address = "127.0.0.1:3002" + alias = "LDK Server" + api_key = "test_api_key" + + [tls] + cert_path = "/path/to/tls.crt" + key_path = "/path/to/tls.key" + hosts = ["example.com", "ldk-server.local"] + + [storage.disk] + dir_path = "/tmp" + + [bitcoind] + rpc_address = "127.0.0.1:8332" + rpc_user = "bitcoind-testuser" + rpc_password = "bitcoind-testpassword" + + [rabbitmq] + connection_string = "rabbitmq_connection_string" + exchange_name = "rabbitmq_exchange_name" + + [liquidity.lsps2_service] + advertise_service = false + channel_opening_fee_ppm = 1000 # 0.1% fee + channel_over_provisioning_ppm = 500000 # 50% extra capacity + min_channel_opening_fee_msat = 10000000 # 10,000 satoshis + min_channel_lifetime = 4320 # ~30 days + max_client_to_self_delay = 1440 # ~10 days + min_payment_size_msat = 10000000 # 10,000 satoshis + max_payment_size_msat = 25000000000 # 0.25 BTC + client_trusts_lsp = true + "#; + + fn default_args_config() -> ArgsConfig { + ArgsConfig { + config_file: None, + node_network: Some(Network::Regtest), + node_listening_address: Some(String::from("localhost:3008")), + node_rest_service_address: Some(String::from("127.0.0.1:3009")), + bitcoind_rpc_address: Some(String::from("127.0.1.9:18443")), + bitcoind_rpc_user: Some(String::from("bitcoind-testuser_cli")), + bitcoind_rpc_password: Some(String::from("bitcoind-testpassword_cli")), + storage_dir_path: Some(String::from("/tmp_cli")), + node_alias: Some(String::from("LDK Server CLI")), + api_key: Some(String::from("test_api_key")), + } + } - [tls] - cert_path = "/path/to/tls.crt" - key_path = "/path/to/tls.key" - hosts = ["example.com", "ldk-server.local"] + fn missing_field_msg(field: &str) -> String { + format!( + "Missing `{}`. Please provide it via config file, CLI argument, or environment variable.", + field + ) + } - [storage.disk] - dir_path = "/tmp" + fn parse_alias(alias_str: &str) -> NodeAlias { + let mut bytes = [0u8; 32]; + let alias_bytes = alias_str.trim().as_bytes(); + bytes[..alias_bytes.len()].copy_from_slice(alias_bytes); + NodeAlias(bytes) + } - [log] - level = "Trace" - file = "/var/log/ldk-server.log" + #[test] + fn test_config_from_file() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_config_from_file.toml"; + + fs::write(storage_path.join(config_file_name), DEFAULT_CONFIG).unwrap(); + let args_config = ArgsConfig { + config_file: Some(storage_path.join(config_file_name).to_string_lossy().to_string()), + node_network: None, + node_listening_address: None, + node_rest_service_address: None, + bitcoind_rpc_address: None, + bitcoind_rpc_user: None, + bitcoind_rpc_password: None, + storage_dir_path: None, + node_alias: None, + api_key: None, + }; - [esplora] - server_url = "https://mempool.space/api" + let config = load_config(&args_config).unwrap(); - [rabbitmq] - connection_string = "rabbitmq_connection_string" - exchange_name = "rabbitmq_exchange_name" - - [liquidity.lsps2_service] - advertise_service = false - channel_opening_fee_ppm = 1000 # 0.1% fee - channel_over_provisioning_ppm = 500000 # 50% extra capacity - min_channel_opening_fee_msat = 10000000 # 10,000 satoshis - min_channel_lifetime = 4320 # ~30 days - max_client_to_self_delay = 1440 # ~10 days - min_payment_size_msat = 10000000 # 10,000 satoshis - max_payment_size_msat = 25000000000 # 0.25 BTC - client_trusts_lsp = true - "#; + let alias = "LDK Server"; - fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + #[cfg(feature = "events-rabbitmq")] + let (expected_rabbit_conn, expected_rabbit_exchange) = + ("rabbitmq_connection_string".to_string(), "rabbitmq_exchange_name".to_string()); - let mut bytes = [0u8; 32]; - let alias = "LDK Server"; - bytes[..alias.len()].copy_from_slice(alias.as_bytes()); + #[cfg(not(feature = "events-rabbitmq"))] + let (expected_rabbit_conn, expected_rabbit_exchange) = (String::new(), String::new()); - let config = load_config(storage_path.join(config_file_name)).unwrap(); let expected = Config { listening_addr: SocketAddress::from_str("localhost:3001").unwrap(), - alias: Some(NodeAlias(bytes)), + alias: Some(parse_alias(alias)), network: Network::Regtest, rest_service_addr: SocketAddr::from_str("127.0.0.1:3002").unwrap(), api_key: "test_api_key".to_string(), @@ -382,11 +626,13 @@ mod tests { key_path: Some("/path/to/tls.key".to_string()), hosts: vec!["example.com".to_string(), "ldk-server.local".to_string()], }), - chain_source: ChainSource::Esplora { - server_url: String::from("https://mempool.space/api"), + chain_source: ChainSource::Rpc { + rpc_address: SocketAddr::from_str("127.0.0.1:8332").unwrap(), + rpc_user: "bitcoind-testuser".to_string(), + rpc_password: "bitcoind-testpassword".to_string(), }, - rabbitmq_connection_string: "rabbitmq_connection_string".to_string(), - rabbitmq_exchange_name: "rabbitmq_exchange_name".to_string(), + rabbitmq_connection_string: expected_rabbit_conn, + rabbitmq_exchange_name: expected_rabbit_exchange, lsps2_service_config: Some(LSPS2ServiceConfig { require_token: None, advertise_service: false, @@ -408,14 +654,7 @@ mod tests { assert_eq!(config.rest_service_addr, expected.rest_service_addr); assert_eq!(config.api_key, expected.api_key); assert_eq!(config.storage_dir_path, expected.storage_dir_path); - assert_eq!(config.tls_config, expected.tls_config); - let ChainSource::Esplora { server_url } = config.chain_source else { - panic!("unexpected config chain source"); - }; - let ChainSource::Esplora { server_url: expected_server_url } = expected.chain_source else { - panic!("unexpected chain source"); - }; - assert_eq!(server_url, expected_server_url); + assert_eq!(config.chain_source, expected.chain_source); assert_eq!(config.rabbitmq_connection_string, expected.rabbitmq_connection_string); assert_eq!(config.rabbitmq_exchange_name, expected.rabbitmq_exchange_name); #[cfg(feature = "experimental-lsps2-support")] @@ -431,6 +670,11 @@ mod tests { alias = "LDK Server" api_key = "test_api_key" + [tls] + cert_path = "/path/to/tls.crt" + key_path = "/path/to/tls.key" + hosts = ["example.com", "ldk-server.local"] + [storage.disk] dir_path = "/tmp" @@ -458,7 +702,7 @@ mod tests { "#; fs::write(storage_path.join(config_file_name), toml_config).unwrap(); - let config = load_config(storage_path.join(config_file_name)).unwrap(); + let config = load_config(&args_config).unwrap(); let ChainSource::Electrum { server_url } = config.chain_source else { panic!("unexpected chain source"); @@ -476,6 +720,11 @@ mod tests { alias = "LDK Server" api_key = "test_api_key" + [tls] + cert_path = "/path/to/tls.crt" + key_path = "/path/to/tls.key" + hosts = ["example.com", "ldk-server.local"] + [storage.disk] dir_path = "/tmp" @@ -505,7 +754,7 @@ mod tests { "#; fs::write(storage_path.join(config_file_name), toml_config).unwrap(); - let config = load_config(storage_path.join(config_file_name)).unwrap(); + let config = load_config(&args_config).unwrap(); let ChainSource::Rpc { rpc_address, rpc_user, rpc_password } = config.chain_source else { panic!("unexpected chain source"); @@ -525,6 +774,11 @@ mod tests { alias = "LDK Server" api_key = "test_api_key" + [tls] + cert_path = "/path/to/tls.crt" + key_path = "/path/to/tls.key" + hosts = ["example.com", "ldk-server.local"] + [storage.disk] dir_path = "/tmp" @@ -557,7 +811,261 @@ mod tests { "#; fs::write(storage_path.join(config_file_name), toml_config).unwrap(); - let error = load_config(storage_path.join(config_file_name)).unwrap_err(); + let error = load_config(&args_config).unwrap_err(); assert_eq!(error.to_string(), "Must set a single chain source, multiple were configured"); } + + #[test] + #[cfg(feature = "experimental-lsps2-support")] + fn test_error_if_lsps2_feature_without_config_file() { + let args_config = ArgsConfig { + config_file: None, + node_network: None, + node_listening_address: None, + node_rest_service_address: None, + node_alias: None, + bitcoind_rpc_address: None, + bitcoind_rpc_user: None, + bitcoind_rpc_password: None, + storage_dir_path: None, + api_key: None, + }; + let result = load_config(&args_config); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), "To use the `experimental-lsps2-support` feature, you must provide a configuration file."); + } + + #[test] + fn test_config_missing_fields_in_file() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_config_missing_fields_in_file.toml"; + let args_config = ArgsConfig { + config_file: Some(storage_path.join(config_file_name).to_string_lossy().to_string()), + node_network: None, + node_listening_address: None, + node_rest_service_address: None, + bitcoind_rpc_address: None, + bitcoind_rpc_user: None, + bitcoind_rpc_password: None, + storage_dir_path: None, + node_alias: None, + api_key: None, + }; + + macro_rules! validate_missing { + ($field:expr, $err_msg:expr) => { + let mut toml_config = DEFAULT_CONFIG.to_string(); + toml_config = remove_config_line(&toml_config, $field); + fs::write(storage_path.join(config_file_name), &toml_config).unwrap(); + let result = load_config(&args_config); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), $err_msg); + }; + } + + #[cfg(feature = "experimental-lsps2-support")] + { + validate_missing!( + "[liquidity.lsps2_service]", + "`liquidity.lsps2_service` must be defined in config if enabling `experimental-lsps2-support` feature." + ); + } + + #[cfg(feature = "events-rabbitmq")] + { + validate_missing!( + "[rabbitmq]", + "Both `rabbitmq.connection_string` and `rabbitmq.exchange_name` must be configured if enabling `events-rabbitmq` feature." + ); + } + + validate_missing!("rpc_password", missing_field_msg("bitcoind_rpc_password")); + validate_missing!("rpc_user", missing_field_msg("bitcoind_rpc_user")); + validate_missing!("rpc_address", missing_field_msg("bitcoind_rpc_address")); + validate_missing!("dir_path =", missing_field_msg("storage_dir_path")); + validate_missing!("rest_service_address =", missing_field_msg("rest_service_address")); + validate_missing!("listening_address =", missing_field_msg("node_listening_address")); + validate_missing!("network =", missing_field_msg("network")); + } + + fn remove_config_line(config: &str, key: &str) -> String { + config + .lines() + .filter(|line| !line.trim_start().starts_with(key)) + .collect::>() + .join("\n") + } + + #[test] + #[cfg(not(feature = "experimental-lsps2-support"))] + #[cfg(not(feature = "events-rabbitmq"))] + fn test_config_from_args_config() { + let args_config = default_args_config(); + let config = load_config(&args_config).unwrap(); + + let expected = Config { + listening_addr: SocketAddress::from_str( + args_config.node_listening_address.as_deref().unwrap(), + ) + .unwrap(), + network: Network::Regtest, + rest_service_addr: SocketAddr::from_str( + args_config.node_rest_service_address.as_deref().unwrap(), + ) + .unwrap(), + api_key: "test_api_key".to_string(), + alias: Some(parse_alias(args_config.node_alias.as_deref().unwrap())), + storage_dir_path: args_config.storage_dir_path.unwrap(), + tls_config: None, + chain_source: ChainSource::Rpc { + rpc_address: SocketAddr::from_str( + args_config.bitcoind_rpc_address.as_deref().unwrap(), + ) + .unwrap(), + rpc_user: args_config.bitcoind_rpc_user.unwrap(), + rpc_password: args_config.bitcoind_rpc_password.unwrap(), + }, + rabbitmq_connection_string: String::new(), + rabbitmq_exchange_name: String::new(), + lsps2_service_config: None, + log_level: LevelFilter::Trace, + log_file_path: Some("/var/log/ldk-server.log".to_string()), + }; + + assert_eq!(config.listening_addr, expected.listening_addr); + assert_eq!(config.network, expected.network); + assert_eq!(config.rest_service_addr, expected.rest_service_addr); + assert_eq!(config.storage_dir_path, expected.storage_dir_path); + assert_eq!(config.chain_source, expected.chain_source); + assert_eq!(config.rabbitmq_connection_string, expected.rabbitmq_connection_string); + assert_eq!(config.rabbitmq_exchange_name, expected.rabbitmq_exchange_name); + assert!(config.lsps2_service_config.is_none()); + } + + #[test] + #[cfg(not(feature = "experimental-lsps2-support"))] + #[cfg(not(feature = "events-rabbitmq"))] + fn test_config_missing_fields_in_args_config() { + macro_rules! validate_missing { + ($field:ident, $err_msg:expr) => { + let mut args_config = default_args_config(); + args_config.$field = None; + let result = load_config(&args_config); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), $err_msg); + }; + } + + validate_missing!(bitcoind_rpc_password, missing_field_msg("bitcoind_rpc_password")); + validate_missing!(bitcoind_rpc_user, missing_field_msg("bitcoind_rpc_user")); + validate_missing!(bitcoind_rpc_address, missing_field_msg("bitcoind_rpc_address")); + validate_missing!(node_network, missing_field_msg("network")); + validate_missing!(node_rest_service_address, missing_field_msg("rest_service_address")); + validate_missing!(storage_dir_path, missing_field_msg("storage_dir_path")); + validate_missing!(node_listening_address, missing_field_msg("node_listening_address")); + } + + #[test] + fn test_args_config_overrides_file() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_args_config_overrides_file.toml"; + + fs::write(storage_path.join(config_file_name), DEFAULT_CONFIG).unwrap(); + let mut args_config: ArgsConfig = default_args_config(); + args_config.config_file = + Some(storage_path.join(config_file_name).to_string_lossy().to_string()); + + #[cfg(feature = "events-rabbitmq")] + let (expected_rabbit_conn, expected_rabbit_exchange) = + ("rabbitmq_connection_string".to_string(), "rabbitmq_exchange_name".to_string()); + + #[cfg(not(feature = "events-rabbitmq"))] + let (expected_rabbit_conn, expected_rabbit_exchange) = (String::new(), String::new()); + + let config = load_config(&args_config).unwrap(); + let expected = Config { + listening_addr: SocketAddress::from_str( + args_config.node_listening_address.as_deref().unwrap(), + ) + .unwrap(), + network: Network::Regtest, + rest_service_addr: SocketAddr::from_str( + args_config.node_rest_service_address.as_deref().unwrap(), + ) + .unwrap(), + api_key: "test_api_key".to_string(), + alias: Some(parse_alias(args_config.node_alias.as_deref().unwrap())), + storage_dir_path: args_config.storage_dir_path.unwrap(), + tls_config: Some(TlsConfig { + cert_path: Some("/path/to/tls.crt".to_string()), + key_path: Some("/path/to/tls.key".to_string()), + hosts: vec!["example.com".to_string(), "ldk-server.local".to_string()], + }), + chain_source: ChainSource::Rpc { + rpc_address: SocketAddr::from_str( + args_config.bitcoind_rpc_address.as_deref().unwrap(), + ) + .unwrap(), + rpc_user: args_config.bitcoind_rpc_user.unwrap(), + rpc_password: args_config.bitcoind_rpc_password.unwrap(), + }, + rabbitmq_connection_string: expected_rabbit_conn, + rabbitmq_exchange_name: expected_rabbit_exchange, + lsps2_service_config: Some(LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 1000, + channel_over_provisioning_ppm: 500000, + min_channel_opening_fee_msat: 10000000, + min_channel_lifetime: 4320, + max_client_to_self_delay: 1440, + min_payment_size_msat: 10000000, + max_payment_size_msat: 25000000000, + client_trusts_lsp: true, + }), + log_level: LevelFilter::Trace, + log_file_path: Some("/var/log/ldk-server.log".to_string()), + }; + + assert_eq!(config.listening_addr, expected.listening_addr); + assert_eq!(config.network, expected.network); + assert_eq!(config.rest_service_addr, expected.rest_service_addr); + assert_eq!(config.storage_dir_path, expected.storage_dir_path); + assert_eq!(config.chain_source, expected.chain_source); + assert_eq!(config.rabbitmq_connection_string, expected.rabbitmq_connection_string); + assert_eq!(config.rabbitmq_exchange_name, expected.rabbitmq_exchange_name); + #[cfg(feature = "experimental-lsps2-support")] + assert_eq!(config.lsps2_service_config.is_some(), expected.lsps2_service_config.is_some()); + } + + #[test] + #[cfg(feature = "events-rabbitmq")] + fn test_error_if_rabbitmq_feature_without_config_file() { + let args_config = ArgsConfig { + config_file: None, + node_network: None, + node_listening_address: None, + node_rest_service_address: None, + node_alias: None, + bitcoind_rpc_address: None, + bitcoind_rpc_user: None, + bitcoind_rpc_password: None, + storage_dir_path: None, + api_key: None, + }; + let result = load_config(&args_config); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "To use the `events-rabbitmq` feature, you must provide a configuration file." + ); + } }