From e5eccc14029445492a45572cae4578ae113ca72d Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 20 May 2026 13:56:31 -0500 Subject: [PATCH 1/2] Reject unknown config options Fail TOML parsing when unsupported config keys are present so typos do not get silently ignored. Keep the LSPS2 service section typed in all builds, while default builds still parse and ignore it unless the experimental feature is enabled. --- ldk-server-client/src/config.rs | 30 +++++++++++ ldk-server/src/util/config.rs | 92 +++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/ldk-server-client/src/config.rs b/ldk-server-client/src/config.rs index f6f023ce..e2dca342 100644 --- a/ldk-server-client/src/config.rs +++ b/ldk-server-client/src/config.rs @@ -70,6 +70,7 @@ pub fn cert_path_for_storage_dir(storage_dir: &str) -> PathBuf { /// Top-level structure of the `ldk-server` configuration TOML file. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Config { /// Node-level configuration. pub node: NodeConfig, @@ -81,6 +82,7 @@ pub struct Config { /// `[tls]` section of the configuration file. #[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct TlsConfig { /// Path to the server's TLS certificate in PEM format. pub cert_path: Option, @@ -88,6 +90,7 @@ pub struct TlsConfig { /// `[node]` section of the configuration file. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NodeConfig { /// Address of the `ldk-server` gRPC service. #[serde(default = "default_grpc_service_address")] @@ -97,6 +100,7 @@ pub struct NodeConfig { /// `[storage]` section of the configuration file. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StorageConfig { /// On-disk storage configuration. pub disk: Option, @@ -104,6 +108,7 @@ pub struct StorageConfig { /// `[storage.disk]` section of the configuration file. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DiskConfig { /// Directory used by the server to store its persistent data. pub dir_path: Option, @@ -202,6 +207,31 @@ mod tests { assert_eq!(config.node.grpc_service_address, DEFAULT_GRPC_SERVICE_ADDRESS); } + #[test] + fn config_rejects_unknown_fields() { + let top_level_err = toml::from_str::( + r#" + [node] + network = "regtest" + + [unknown] + option = true + "#, + ) + .unwrap_err(); + assert!(top_level_err.to_string().contains("unknown field `unknown`")); + + let node_err = toml::from_str::( + r#" + [node] + network = "regtest" + unknown = true + "#, + ) + .unwrap_err(); + assert!(node_err.to_string().contains("unknown field `unknown`")); + } + #[test] fn resolve_base_url_uses_cli_arg_first() { let config: Config = toml::from_str( diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index 61af98f5..318be1fa 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -460,6 +460,7 @@ impl ConfigBuilder { /// Configuration loaded from a TOML file. #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct TomlConfig { node: Option, storage: Option, @@ -475,6 +476,7 @@ pub struct TomlConfig { } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct NodeConfig { network: Option, listening_addresses: Option>, @@ -487,16 +489,19 @@ struct NodeConfig { } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct StorageConfig { disk: Option, } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct DiskConfig { dir_path: Option, } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct BitcoindConfig { rpc_address: Option, rpc_user: Option, @@ -504,22 +509,26 @@ struct BitcoindConfig { } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct ElectrumConfig { server_url: String, } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct EsploraConfig { server_url: String, } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct LogConfig { level: Option, file: Option, } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct TomlTlsConfig { cert_path: Option, key_path: Option, @@ -527,6 +536,7 @@ struct TomlTlsConfig { } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct MetricsTomlConfig { enabled: Option, poll_metrics_interval: Option, @@ -535,11 +545,13 @@ struct MetricsTomlConfig { } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct TomlTorConfig { proxy_address: String, } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct HrnTomlConfig { mode: Option, dns_server_address: Option, @@ -645,12 +657,14 @@ fn parse_async_payments_role(role: &str) -> io::Result { } #[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct LiquidityConfig { lsps2_client: Option, lsps2_service: Option, } #[derive(Deserialize, Serialize, Debug)] +#[serde(deny_unknown_fields)] struct LSPSClientTomlConfig { node_pubkey: String, address: String, @@ -658,6 +672,7 @@ struct LSPSClientTomlConfig { } #[derive(Deserialize, Serialize, Debug)] +#[serde(deny_unknown_fields)] struct LSPS2ServiceTomlConfig { advertise_service: bool, channel_opening_fee_ppm: u32, @@ -1343,6 +1358,73 @@ mod tests { validate_missing!("network =", missing_field_msg("network")); } + #[test] + fn test_config_unknown_fields_in_file() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_config_unknown_fields_in_file.toml"; + + let mut args_config = empty_args_config(); + args_config.config_file = + Some(storage_path.join(config_file_name).to_string_lossy().to_string()); + + fs::write( + storage_path.join(config_file_name), + format!("{}\n[unknown]\noption = true\n", DEFAULT_CONFIG), + ) + .unwrap(); + let err = load_config(&args_config).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(err.to_string().contains("unknown field `unknown`")); + + fs::write( + storage_path.join(config_file_name), + DEFAULT_CONFIG + .replace("network = \"regtest\"", "network = \"regtest\"\nunknown = true"), + ) + .unwrap(); + let err = load_config(&args_config).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(err.to_string().contains("unknown field `unknown`")); + } + + #[test] + #[cfg(not(feature = "experimental-lsps2-support"))] + fn test_config_allows_unused_lsps2_service_config_without_feature() { + let storage_path = std::env::temp_dir(); + let config_file_name = + "test_config_allows_unused_lsps2_service_config_without_feature.toml"; + + let mut args_config = empty_args_config(); + args_config.config_file = + Some(storage_path.join(config_file_name).to_string_lossy().to_string()); + + let toml_config = r#" + [node] + network = "regtest" + + [bitcoind] + rpc_address = "127.0.0.1:8332" + rpc_user = "bitcoind-testuser" + rpc_password = "bitcoind-testpassword" + + [liquidity.lsps2_service] + 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 + disable_client_reserve = false + "#; + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + let config = load_config(&args_config).unwrap(); + assert!(config.lsps2_service_config.is_none()); + } + fn remove_config_line(config: &str, key: &str) -> String { config .lines() @@ -1573,7 +1655,6 @@ mod tests { let toml_config = r#" [node] network = "regtest" - rest_service_address = "127.0.0.1:3002" [bitcoind] rpc_address = "127.0.0.1:8332" @@ -1585,10 +1666,6 @@ mod tests { username = "admin" password = "password123" - [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 @@ -1621,7 +1698,6 @@ mod tests { let toml_config = r#" [node] network = "regtest" - rest_service_address = "127.0.0.1:3002" [bitcoind] rpc_address = "127.0.0.1:8332" @@ -1632,10 +1708,6 @@ mod tests { enabled = true username = "admin" - [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 From d71a479b3eb2b9a8feafed1c4d7a2dfa9d8c193b Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 21 May 2026 13:09:24 -0500 Subject: [PATCH 2/2] Don't silently fail to load config in cli Before we would silently drop the config if we failed to parse it. This can cause confusion as to why your cli isn't working correctly. Now if you have a config file it'll throw an error if we can't parse it instead of silently dropping it. --- ldk-server-cli/src/main.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index b0c346df..d6840d71 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -563,7 +563,20 @@ async fn main() { } 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 config = match config_path.as_ref() { + None => None, + Some(path) => { + if path.is_file() { + let cfg = load_config(path).unwrap_or_else(|e| { + eprintln!("Failed to load config file '{}': {}", path.display(), e); + std::process::exit(1); + }); + Some(cfg) + } else { + None + } + }, + }; let api_key = resolve_api_key(cli.api_key, config.as_ref()).unwrap_or_else(|| { eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at {DEFAULT_DIR}/[network]/api_key");