diff --git a/Cargo.lock b/Cargo.lock index a771737..bb849fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1760,10 +1760,12 @@ name = "ldk-server-cli" version = "0.1.0" dependencies = [ "clap", + "hex-conservative 0.2.1", "ldk-server-client", "serde", "serde_json", "tokio", + "toml", ] [[package]] diff --git a/ldk-server-cli/Cargo.toml b/ldk-server-cli/Cargo.toml index bc77d1d..2ac0c4c 100644 --- a/ldk-server-cli/Cargo.toml +++ b/ldk-server-cli/Cargo.toml @@ -6,6 +6,8 @@ edition = "2021" [dependencies] ldk-server-client = { path = "../ldk-server-client", features = ["serde"] } clap = { version = "4.0.5", default-features = false, features = ["derive", "std", "error-context", "suggestions", "help"] } +hex-conservative = { version = "0.2", default-features = false, features = ["std"] } tokio = { version = "1.38.0", default-features = false, features = ["rt-multi-thread", "macros"] } serde = "1.0" serde_json = "1.0" +toml = { version = "0.8", default-features = false, features = ["parse"] } diff --git a/ldk-server-cli/src/config.rs b/ldk-server-cli/src/config.rs new file mode 100644 index 0000000..5a0b618 --- /dev/null +++ b/ldk-server-cli/src/config.rs @@ -0,0 +1,93 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const DEFAULT_CONFIG_FILE: &str = "config.toml"; +const DEFAULT_CERT_FILE: &str = "tls.crt"; +const API_KEY_FILE: &str = "api_key"; + +pub fn get_default_data_dir() -> Option { + #[cfg(target_os = "macos")] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join("Library/Application Support/ldk-server")) + } + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA").ok().map(|appdata| PathBuf::from(appdata).join("ldk-server")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join(".ldk-server")) + } +} + +pub fn get_default_config_path() -> Option { + get_default_data_dir().map(|dir| dir.join(DEFAULT_CONFIG_FILE)) +} + +pub fn get_default_cert_path(network: &str) -> Option { + get_default_data_dir().map(|path| { + if network == "bitcoin" { + path.join(DEFAULT_CERT_FILE) + } else { + path.join(network).join(DEFAULT_CERT_FILE) + } + }) +} + +pub fn get_default_api_key_path(network: &str) -> Option { + get_default_data_dir().map(|path| { + if network == "bitcoin" { + path.join(API_KEY_FILE) + } else { + path.join(network).join(API_KEY_FILE) + } + }) +} + +#[derive(Debug, Deserialize)] +pub struct Config { + pub node: NodeConfig, + pub tls: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TlsConfig { + pub cert_path: Option, +} + +#[derive(Debug, Deserialize)] +pub struct NodeConfig { + pub rest_service_address: String, + network: String, +} + +impl Config { + pub fn network(&self) -> Result { + match self.node.network.as_str() { + "bitcoin" | "mainnet" => Ok("bitcoin".to_string()), + "testnet" => Ok("testnet".to_string()), + "testnet4" => Ok("testnet4".to_string()), + "signet" => Ok("signet".to_string()), + "regtest" => Ok("regtest".to_string()), + other => Err(format!("Unsupported network: {other}")), + } + } +} + +pub fn load_config(path: &PathBuf) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?; + toml::from_str(&contents) + .map_err(|e| format!("Failed to parse config file '{}': {}", path.display(), e)) +} diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index fb9559c..ac93e84 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -8,6 +8,10 @@ // licenses. use clap::{Parser, Subcommand}; +use config::{ + get_default_api_key_path, get_default_cert_path, get_default_config_path, load_config, +}; +use hex_conservative::DisplayHex; use ldk_server_client::client::LdkServerClient; use ldk_server_client::error::LdkServerError; use ldk_server_client::error::LdkServerErrorCode::{ @@ -28,8 +32,10 @@ use ldk_server_client::ldk_server_protos::types::{ RouteParametersConfig, }; use serde::Serialize; +use std::path::PathBuf; use types::CliListPaymentsResponse; +mod config; mod types; // Having these default values as constants in the Proto file and @@ -43,19 +49,25 @@ const DEFAULT_EXPIRY_SECS: u32 = 86_400; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Cli { - #[arg(short, long, default_value = "localhost:3000")] - base_url: String, + #[arg(short, long, help = "Base URL of the server. If not provided, reads from config file")] + base_url: Option, - #[arg(short, long, required(true))] - api_key: String, + #[arg( + short, + long, + help = "API key for authentication. If not provided, reads from config file" + )] + api_key: Option, #[arg( short, long, - required(true), - help = "Path to the server's TLS certificate file (PEM format). Found at /tls_cert.pem" + help = "Path to the server's TLS certificate file (PEM format). If not provided, uses ~/.ldk-server/[network]/tls.crt" )] - tls_cert: String, + tls_cert: Option, + + #[arg(short, long, help = "Path to config file. Defaults to ~/.ldk-server/config.toml")] + config: Option, #[command(subcommand)] command: Commands, @@ -226,18 +238,58 @@ enum Commands { async fn main() { let cli = Cli::parse(); - // Load server certificate for TLS verification - let server_cert_pem = std::fs::read(&cli.tls_cert).unwrap_or_else(|e| { - eprintln!("Failed to read server certificate file '{}': {}", cli.tls_cert, e); - std::process::exit(1); - }); + let config_path = cli.config.map(PathBuf::from).or_else(get_default_config_path); + let config = config_path.as_ref().and_then(|p| load_config(p).ok()); - let client = - LdkServerClient::new(cli.base_url, cli.api_key, &server_cert_pem).unwrap_or_else(|e| { - eprintln!("Failed to create client: {e}"); + // Get API key from argument, then from api_key file + let api_key = cli + .api_key + .or_else(|| { + // Try to read from api_key file based on network (file contains raw bytes) + let network = config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string()); + get_default_api_key_path(&network) + .and_then(|path| std::fs::read(&path).ok()) + .map(|bytes| bytes.to_lower_hex_string()) + }) + .unwrap_or_else(|| { + eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at ~/.ldk-server/[network]/api_key"); std::process::exit(1); }); + // Get base URL from argument then from config file + let base_url = + cli.base_url.or_else(|| config.as_ref().map(|c| c.node.rest_service_address.clone())) + .unwrap_or_else(|| { + eprintln!("Base URL not provided. Use --base-url or ensure config file exists at ~/.ldk-server/config.toml"); + std::process::exit(1); + }); + + // Get TLS cert path from argument, then from config file, then try default location + let tls_cert_path = cli.tls_cert.map(PathBuf::from).or_else(|| { + config + .as_ref() + .and_then(|c| c.tls.as_ref().and_then(|t| t.cert_path.as_ref().map(PathBuf::from))) + .or_else(|| { + config + .as_ref() + .and_then(|c| c.network().ok().and_then(|n| get_default_cert_path(&n))) + }) + }) + .unwrap_or_else(|| { + eprintln!("TLS cert path not provided. Use --tls-cert or ensure config file exists at ~/.ldk-server/config.toml"); + std::process::exit(1); + }); + + let server_cert_pem = std::fs::read(&tls_cert_path).unwrap_or_else(|e| { + eprintln!("Failed to read server certificate file '{}': {}", tls_cert_path.display(), e); + std::process::exit(1); + }); + + let client = LdkServerClient::new(base_url, api_key, &server_cert_pem).unwrap_or_else(|e| { + eprintln!("Failed to create client: {e}"); + std::process::exit(1); + }); + match cli.command { Commands::GetNodeInfo => { handle_response_result::<_, GetNodeInfoResponse>( diff --git a/ldk-server/ldk-server-config.toml b/ldk-server/ldk-server-config.toml index f4485e9..0cb5a27 100644 --- a/ldk-server/ldk-server-config.toml +++ b/ldk-server/ldk-server-config.toml @@ -3,7 +3,6 @@ network = "regtest" # Bitcoin network to use listening_address = "localhost:3001" # Lightning node listening address rest_service_address = "127.0.0.1:3002" # LDK Server REST address -api_key = "your-secret-api-key" # API key for authenticating REST requests # Storage settings [storage.disk] diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 934098f..b6cb9f9 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -13,6 +13,7 @@ mod service; mod util; use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -20,6 +21,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use hex::DisplayHex; use hyper::server::conn::http1; use hyper_util::rt::TokioIo; +use ldk_node::bitcoin::Network; use ldk_node::config::Config; use ldk_node::entropy::NodeEntropy; use ldk_node::lightning::ln::channelmanager::PaymentId; @@ -27,7 +29,7 @@ use ldk_node::{Builder, Event, Node}; use ldk_server_protos::events; use ldk_server_protos::events::{event_envelope, EventEnvelope}; use ldk_server_protos::types::Payment; -use log::{error, info}; +use log::{debug, error, info}; use prost::Message; use rand::Rng; use tokio::net::TcpListener; @@ -51,29 +53,65 @@ 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; -const USAGE_GUIDE: &str = "Usage: ldk-server "; +const DEFAULT_CONFIG_FILE: &str = "config.toml"; +const API_KEY_FILE: &str = "api_key"; + +fn get_default_data_dir() -> Option { + #[cfg(target_os = "macos")] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join("Library/Application Support/ldk-server")) + } + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA").ok().map(|appdata| PathBuf::from(appdata).join("ldk-server")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join(".ldk-server")) + } +} + +fn get_default_config_path() -> Option { + get_default_data_dir().map(|data_dir| data_dir.join(DEFAULT_CONFIG_FILE)) +} + +const USAGE_GUIDE: &str = "Usage: ldk-server [config_path] + +If no config path is provided, ldk-server will look for a config file at: + Linux: ~/.ldk-server/config.toml + macOS: ~/Library/Application Support/ldk-server/config.toml + Windows: %APPDATA%\\ldk-server\\config.toml"; 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); - } + let config_path: PathBuf = if args.len() < 2 { + match get_default_config_path() { + Some(path) => path, + None => { + eprintln!("Unable to determine home directory for default config path."); + eprintln!("{USAGE_GUIDE}"); + std::process::exit(-1); + }, + } + } else { + let arg = args[1].as_str(); + if arg == "-h" || arg == "--help" { + println!("{USAGE_GUIDE}"); + std::process::exit(0); + } + PathBuf::from(arg) + }; - if fs::File::open(arg).is_err() { - eprintln!("Unable to access configuration file."); + if fs::File::open(&config_path).is_err() { + eprintln!("Unable to access configuration file: {}", config_path.display()); std::process::exit(-1); } let mut ldk_node_config = Config::default(); - let config_file = match load_config(Path::new(arg)) { + let config_file = match load_config(&config_path) { Ok(config) => config, Err(e) => { eprintln!("Invalid configuration file: {}", e); @@ -81,13 +119,35 @@ fn main() { }, }; + let storage_dir: PathBuf = match config_file.storage_dir_path { + None => { + let default = get_default_data_dir(); + match default { + Some(path) => { + // Add network subdirectory for test networks to make sure we don't overwrite + // data when switching between networks. + if config_file.network != Network::Bitcoin { + path.join(config_file.network.to_string()) + } else { + path + } + }, + None => { + eprintln!("Unable to determine home directory for default storage path."); + std::process::exit(-1); + }, + } + }, + Some(configured_path) => PathBuf::from(configured_path), + }; + let log_file_path = config_file.log_file_path.map(PathBuf::from).unwrap_or_else(|| { - let mut default_log_path = PathBuf::from(&config_file.storage_dir_path); + let mut default_log_path = storage_dir.clone(); default_log_path.push("ldk-server.log"); default_log_path }); - if log_file_path == PathBuf::from(&config_file.storage_dir_path) { + if log_file_path == storage_dir { eprintln!("Log file path cannot be the same as storage directory path."); std::process::exit(-1); } @@ -100,7 +160,15 @@ fn main() { }, }; - ldk_node_config.storage_dir_path = config_file.storage_dir_path.clone(); + let api_key = match load_or_generate_api_key(&storage_dir) { + Ok(key) => key, + Err(e) => { + eprintln!("Failed to load or generate API key: {e}"); + std::process::exit(-1); + }, + }; + + ldk_node_config.storage_dir_path = storage_dir.to_str().unwrap().to_string(); ldk_node_config.listening_addresses = Some(vec![config_file.listening_addr]); ldk_node_config.network = config_file.network; @@ -147,7 +215,7 @@ fn main() { builder.set_runtime(runtime.handle().clone()); - let seed_path = format!("{}/keys_seed", config_file.storage_dir_path); + let seed_path = storage_dir.join("keys_seed").to_str().unwrap().to_string(); let node_entropy = match NodeEntropy::from_seed_path(seed_path) { Ok(entropy) => entropy, Err(e) => { @@ -164,15 +232,14 @@ fn main() { }, }; - let paginated_store: Arc = Arc::new( - match SqliteStore::new(PathBuf::from(&config_file.storage_dir_path), None, None) { + let paginated_store: Arc = + Arc::new(match SqliteStore::new(storage_dir.clone(), None, None) { Ok(store) => store, Err(e) => { error!("Failed to create SqliteStore: {e:?}"); std::process::exit(-1); }, - }, - ); + }); #[cfg(not(feature = "events-rabbitmq"))] let event_publisher: Arc = @@ -226,7 +293,7 @@ fn main() { let server_config = match get_or_generate_tls_config( config_file.tls_config, - &config_file.storage_dir_path, + storage_dir.to_str().unwrap(), ) { Ok(config) => config, Err(e) => { @@ -379,7 +446,7 @@ fn main() { res = rest_svc_listener.accept() => { match res { Ok((stream, _)) => { - let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), config_file.api_key.clone()); + let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), api_key.clone()); let acceptor = tls_acceptor.clone(); runtime.spawn(async move { match acceptor.accept(stream).await { @@ -464,3 +531,29 @@ fn upsert_payment_details( }, } } + +/// Loads the API key from a file, or generates a new one if it doesn't exist. +/// The API key file is stored with 0400 permissions (read-only for owner). +fn load_or_generate_api_key(storage_dir: &Path) -> std::io::Result { + let api_key_path = storage_dir.join(API_KEY_FILE); + + if api_key_path.exists() { + let key_bytes = fs::read(&api_key_path)?; + Ok(key_bytes.to_lower_hex_string()) + } else { + // Generate a 32-byte random API key + let mut rng = rand::thread_rng(); + let mut key_bytes = [0u8; 32]; + rng.fill(&mut key_bytes); + + // Write the raw bytes to the file + fs::write(&api_key_path, key_bytes)?; + + // Set permissions to 0400 (read-only for owner) + let permissions = fs::Permissions::from_mode(0o400); + fs::set_permissions(&api_key_path, permissions)?; + + debug!("Generated new API key at {}", api_key_path.display()); + Ok(key_bytes.to_lower_hex_string()) + } +} diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index b3fb427..5554fdd 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -25,10 +25,9 @@ pub struct Config { pub listening_addr: SocketAddress, pub alias: Option, pub network: Network, - pub api_key: String, pub tls_config: Option, pub rest_service_addr: SocketAddr, - pub storage_dir_path: String, + pub storage_dir_path: Option, pub chain_source: ChainSource, pub rabbitmq_connection_string: String, pub rabbitmq_exchange_name: String, @@ -165,8 +164,7 @@ impl TryFrom for Config { network: toml_config.node.network, alias, rest_service_addr, - api_key: toml_config.node.api_key, - storage_dir_path: toml_config.storage.disk.dir_path, + storage_dir_path: toml_config.storage.and_then(|s| s.disk.and_then(|d| d.dir_path)), chain_source, rabbitmq_connection_string, rabbitmq_exchange_name, @@ -182,7 +180,7 @@ impl TryFrom for Config { #[derive(Deserialize, Serialize)] pub struct TomlConfig { node: NodeConfig, - storage: StorageConfig, + storage: Option, bitcoind: Option, electrum: Option, esplora: Option, @@ -198,17 +196,16 @@ struct NodeConfig { listening_address: String, rest_service_address: String, alias: Option, - api_key: String, } #[derive(Deserialize, Serialize)] struct StorageConfig { - disk: DiskConfig, + disk: Option, } #[derive(Deserialize, Serialize)] struct DiskConfig { - dir_path: String, + dir_path: Option, } #[derive(Deserialize, Serialize)] @@ -334,7 +331,6 @@ mod tests { 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" @@ -354,7 +350,7 @@ mod tests { [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 @@ -379,8 +375,7 @@ mod tests { alias: Some(NodeAlias(bytes)), network: Network::Regtest, rest_service_addr: SocketAddr::from_str("127.0.0.1:3002").unwrap(), - api_key: "test_api_key".to_string(), - storage_dir_path: "/tmp".to_string(), + storage_dir_path: Some("/tmp".to_string()), tls_config: Some(TlsConfig { cert_path: Some("/path/to/tls.crt".to_string()), key_path: Some("/path/to/tls.key".to_string()), @@ -410,7 +405,6 @@ mod tests { 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.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 { @@ -433,7 +427,6 @@ mod tests { listening_address = "localhost:3001" rest_service_address = "127.0.0.1:3002" alias = "LDK Server" - api_key = "test_api_key" [storage.disk] dir_path = "/tmp" @@ -478,7 +471,6 @@ mod tests { listening_address = "localhost:3001" rest_service_address = "127.0.0.1:3002" alias = "LDK Server" - api_key = "test_api_key" [storage.disk] dir_path = "/tmp" @@ -495,7 +487,7 @@ mod tests { [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 @@ -527,7 +519,6 @@ mod tests { listening_address = "localhost:3001" rest_service_address = "127.0.0.1:3002" alias = "LDK Server" - api_key = "test_api_key" [storage.disk] dir_path = "/tmp" @@ -547,7 +538,7 @@ mod tests { [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