From d89ab71f856a78728391411286d5b336b4db3d1e Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 11 Jun 2026 13:12:28 +0000 Subject: [PATCH] Implement CLI argument parsing with clap and config file loading - Add Cli struct with clap derive macros for --config, --validate, --allow-wildcard-bind flags - Config loading: reads TOML, deserializes into StaticConfig + DynamicConfig, validates - --validate: load, validate, print success/errors, exit 0 or 1 - --allow-wildcard-bind is OR'd with config allow_wildcard_bind field - Default config path: /etc/reverse-proxy/config.toml - Version from Cargo.toml via clap - Unit tests for CLI argument parsing and config loading - Integration tests for --validate with valid/invalid config and --allow-wildcard-bind --- src/cli.rs | 387 ++++++++++++++++++++++++++++++++++++ src/config/static_config.rs | 9 +- src/lib.rs | 1 + src/main.rs | 21 +- tests/integration_test.rs | 142 +++++++++++++ 5 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 src/cli.rs diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..3f59767 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,387 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use clap::Parser; + +use crate::config::dynamic_config::{ + BodyConfig, DynamicConfig, RateLimitConfig, SerializableDynamicConfig, +}; +use crate::config::static_config::StaticConfig; +use crate::config::validation::validate; + +const DEFAULT_CONFIG_PATH: &str = "/etc/reverse-proxy/config.toml"; + +#[derive(Debug, Parser)] +#[command(name = "reverse-proxy", version, about = "Reverse proxy server")] +pub struct Cli { + #[arg(long, default_value = DEFAULT_CONFIG_PATH, help = "Path to config file")] + pub config: String, + + #[arg(long, help = "Validate config and exit")] + pub validate: bool, + + #[arg( + long, + help = "Permit 0.0.0.0 as a bind address (for container deployments)" + )] + pub allow_wildcard_bind: bool, +} + +#[derive(Debug)] +pub struct LoadedConfig { + pub static_config: StaticConfig, + pub dynamic_config: DynamicConfig, + pub allow_wildcard_bind: bool, +} + +pub fn parse() -> Cli { + Cli::parse() +} + +pub fn parse_from(args: I) -> Cli +where + I: IntoIterator, + S: Into + Clone, +{ + Cli::parse_from(args) +} + +#[derive(Debug, serde::Deserialize)] +struct RawConfig { + #[serde(default)] + listeners: Vec, + #[serde(default)] + allow_wildcard_bind: bool, + #[serde(default = "crate::config::static_config::default_health_check_port")] + health_check_port: u16, + #[serde(default = "crate::config::static_config::default_admin_socket_path")] + admin_socket_path: String, + #[serde(default = "crate::config::static_config::default_shutdown_timeout_secs")] + shutdown_timeout_secs: u64, + #[serde(default)] + logging: crate::config::static_config::LoggingConfig, + rate_limit: RateLimitConfig, + body: BodyConfig, +} + +pub fn load_config(cli: &Cli) -> Result { + let config_path = Path::new(&cli.config); + let config_content = std::fs::read_to_string(config_path) + .with_context(|| format!("failed to read config file: {}", cli.config))?; + + let raw: RawConfig = toml::from_str(&config_content) + .with_context(|| format!("failed to parse config file: {}", cli.config))?; + + let static_config = StaticConfig { + listeners: raw.listeners, + allow_wildcard_bind: raw.allow_wildcard_bind, + health_check_port: raw.health_check_port, + admin_socket_path: raw.admin_socket_path, + shutdown_timeout_secs: raw.shutdown_timeout_secs, + logging: raw.logging, + }; + + let serializable_dynamic = SerializableDynamicConfig { + sites: collect_sites(&static_config), + rate_limit: raw.rate_limit, + body: raw.body, + }; + + let dynamic_config: DynamicConfig = serializable_dynamic.into(); + + let allow_wildcard_bind = static_config.allow_wildcard_bind || cli.allow_wildcard_bind; + + validate(&static_config, &dynamic_config, cli.allow_wildcard_bind).map_err(|errors| { + anyhow::anyhow!( + "config validation failed:\n{}", + errors + .iter() + .map(|e| format!(" - {}", e)) + .collect::>() + .join("\n") + ) + })?; + + Ok(LoadedConfig { + static_config, + dynamic_config, + allow_wildcard_bind, + }) +} + +fn collect_sites(static_config: &StaticConfig) -> Vec { + let mut sites = Vec::new(); + for listener in &static_config.listeners { + sites.extend(listener.sites.clone()); + } + sites +} + +pub fn run_validate(cli: &Cli) -> Result<()> { + match load_config(cli) { + Ok(_) => { + println!("Configuration is valid."); + Ok(()) + } + Err(e) => { + eprintln!("{}", e); + Err(e) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_default_config_path() { + let cli = Cli::parse_from(["reverse-proxy"]); + assert_eq!(cli.config, DEFAULT_CONFIG_PATH); + assert!(!cli.validate); + assert!(!cli.allow_wildcard_bind); + } + + #[test] + fn cli_custom_config_path() { + let cli = Cli::parse_from(["reverse-proxy", "--config", "/tmp/test.toml"]); + assert_eq!(cli.config, "/tmp/test.toml"); + } + + #[test] + fn cli_validate_flag() { + let cli = Cli::parse_from(["reverse-proxy", "--validate"]); + assert!(cli.validate); + } + + #[test] + fn cli_allow_wildcard_bind_flag() { + let cli = Cli::parse_from(["reverse-proxy", "--allow-wildcard-bind"]); + assert!(cli.allow_wildcard_bind); + } + + #[test] + fn cli_all_flags() { + let cli = Cli::parse_from([ + "reverse-proxy", + "--config", + "/custom/path.toml", + "--validate", + "--allow-wildcard-bind", + ]); + assert_eq!(cli.config, "/custom/path.toml"); + assert!(cli.validate); + assert!(cli.allow_wildcard_bind); + } + + #[test] + fn cli_version_flag() { + let result = Cli::try_parse_from(["reverse-proxy", "--version"]); + assert!(result.is_err()); + if let Err(err) = result { + use clap::error::ErrorKind; + assert!(matches!(err.kind(), ErrorKind::DisplayVersion)); + } + } + + #[test] + fn load_config_missing_file() { + let cli = Cli::parse_from(["reverse-proxy", "--config", "/nonexistent/config.toml"]); + let result = load_config(&cli); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("failed to read config file") || err_msg.contains("No such file")); + } + + #[test] + fn load_config_invalid_toml() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bad.toml"); + std::fs::write(&path, "not valid toml {{{{").unwrap(); + let cli = Cli::parse_from(["reverse-proxy", "--config", path.to_str().unwrap()]); + let result = load_config(&cli); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("failed to parse config file")); + } + + #[test] + fn load_config_valid_file() { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + std::fs::write(&cert_path, "cert").unwrap(); + std::fs::write(&key_path, "key").unwrap(); + + let toml = format!( + r#" +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "127.0.0.1" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "manual" +cert_path = "{}" +key_path = "{}" + +[[listeners.sites]] +host = "test.local" +upstream = "127.0.0.1:8080" +"#, + cert_path.to_str().unwrap(), + key_path.to_str().unwrap() + ); + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, toml).unwrap(); + + let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]); + let result = load_config(&cli); + assert!(result.is_ok()); + let loaded = result.unwrap(); + assert!(!loaded.allow_wildcard_bind); + assert_eq!(loaded.static_config.listeners.len(), 1); + assert_eq!(loaded.dynamic_config.sites.len(), 1); + } + + #[test] + fn load_config_wildcard_bind_or_logic() { + let dir = tempfile::tempdir().unwrap(); + + let toml = r#" +allow_wildcard_bind = false + +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "0.0.0.0" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "acme" +acme_domains = ["test.local"] +acme_cache_dir = "/tmp/acme" +"#; + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, toml).unwrap(); + + let cli = Cli::parse_from([ + "reverse-proxy", + "--config", + config_path.to_str().unwrap(), + "--allow-wildcard-bind", + ]); + let result = load_config(&cli); + assert!(result.is_ok()); + let loaded = result.unwrap(); + assert!(loaded.allow_wildcard_bind); + } + + #[test] + fn load_config_wildcard_bind_rejected_without_flag() { + let dir = tempfile::tempdir().unwrap(); + + let toml = r#" +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "0.0.0.0" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "acme" +acme_domains = ["test.local"] +acme_cache_dir = "/tmp/acme" +"#; + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, toml).unwrap(); + + let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]); + let result = load_config(&cli); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("config validation failed")); + } + + #[test] + fn load_config_validation_fails_reports_errors() { + let dir = tempfile::tempdir().unwrap(); + + let toml = r#" +[rate_limit] +requests_per_second = 0 +burst = 20 + +[body] +limit_bytes = 0 +"#; + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, toml).unwrap(); + + let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]); + let result = load_config(&cli); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("config validation failed")); + } + + #[test] + fn load_config_allow_wildcard_from_config_only() { + let dir = tempfile::tempdir().unwrap(); + + let toml = r#" +allow_wildcard_bind = true + +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "0.0.0.0" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "acme" +acme_domains = ["test.local"] +acme_cache_dir = "/tmp/acme" +"#; + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, toml).unwrap(); + + let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]); + let result = load_config(&cli); + assert!(result.is_ok()); + assert!(result.unwrap().allow_wildcard_bind); + } + + #[test] + fn parse_from_vec() { + let args = vec!["reverse-proxy", "--validate", "--allow-wildcard-bind"]; + let cli = parse_from(args); + assert!(cli.validate); + assert!(cli.allow_wildcard_bind); + } +} diff --git a/src/config/static_config.rs b/src/config/static_config.rs index 444d415..58e9bb8 100644 --- a/src/config/static_config.rs +++ b/src/config/static_config.rs @@ -16,18 +16,15 @@ pub struct StaticConfig { pub logging: LoggingConfig, } -#[allow(dead_code)] -fn default_health_check_port() -> u16 { +pub fn default_health_check_port() -> u16 { 9900 } -#[allow(dead_code)] -fn default_admin_socket_path() -> String { +pub fn default_admin_socket_path() -> String { "/run/reverse-proxy/admin.sock".to_string() } -#[allow(dead_code)] -fn default_shutdown_timeout_secs() -> u64 { +pub fn default_shutdown_timeout_secs() -> u64 { 30 } diff --git a/src/lib.rs b/src/lib.rs index 59977c2..f74e3da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod cli; pub mod config; pub mod health; pub mod logging; diff --git a/src/main.rs b/src/main.rs index 7df3ca4..51c11fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,22 @@ +use reverse_proxy::cli; + fn main() { - tracing::info!("reverse-proxy starting"); + let args = cli::parse(); + + if args.validate { + match cli::run_validate(&args) { + Ok(()) => std::process::exit(0), + Err(_) => std::process::exit(1), + } + } + + match cli::load_config(&args) { + Ok(_config) => { + tracing::info!("reverse-proxy starting"); + } + Err(e) => { + eprintln!("error: {e:#}"); + std::process::exit(1); + } + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 05e16fa..6457301 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,5 +1,6 @@ mod helpers; +use std::process::Command; use std::sync::Arc; use std::time::Duration; @@ -248,3 +249,144 @@ async fn test_rate_limit_eviction_task() { handle.abort(); } + +fn write_valid_config(dir: &std::path::Path) -> std::path::PathBuf { + let cert_path = dir.join("cert.pem"); + let key_path = dir.join("key.pem"); + std::fs::write(&cert_path, "cert").unwrap(); + std::fs::write(&key_path, "key").unwrap(); + + let toml = format!( + r#" +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "127.0.0.1" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "manual" +cert_path = "{}" +key_path = "{}" + +[[listeners.sites]] +host = "test.local" +upstream = "127.0.0.1:8080" +"#, + cert_path.to_str().unwrap(), + key_path.to_str().unwrap() + ); + let config_path = dir.join("valid_config.toml"); + std::fs::write(&config_path, toml).unwrap(); + config_path +} + +fn write_invalid_config(dir: &std::path::Path) -> std::path::PathBuf { + let toml = r#" +[rate_limit] +requests_per_second = 0 +burst = 20 + +[body] +limit_bytes = 0 +"#; + let config_path = dir.join("invalid_config.toml"); + std::fs::write(&config_path, toml).unwrap(); + config_path +} + +fn binary_path() -> std::path::PathBuf { + let bin = env!("CARGO_BIN_EXE_reverse-proxy"); + std::path::PathBuf::from(bin) +} + +#[test] +fn test_validate_valid_config_exits_0() { + let dir = tempfile::tempdir().unwrap(); + let config_path = write_valid_config(dir.path()); + let output = Command::new(binary_path()) + .arg("--config") + .arg(config_path.to_str().unwrap()) + .arg("--validate") + .output() + .unwrap(); + assert!( + output.status.success(), + "expected exit 0, got {}: stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("valid")); +} + +#[test] +fn test_validate_invalid_config_exits_1() { + let dir = tempfile::tempdir().unwrap(); + let config_path = write_invalid_config(dir.path()); + let output = Command::new(binary_path()) + .arg("--config") + .arg(config_path.to_str().unwrap()) + .arg("--validate") + .output() + .unwrap(); + assert!(!output.status.success(), "expected exit 1, got success"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("validation failed") || stderr.contains("error")); +} + +#[test] +fn test_validate_missing_config_file_exits_1() { + let output = Command::new(binary_path()) + .arg("--config") + .arg("/nonexistent/path/config.toml") + .arg("--validate") + .output() + .unwrap(); + assert!(!output.status.success(), "expected exit 1, got success"); +} + +#[test] +fn test_validate_wildcard_bind_via_cli_flag() { + let dir = tempfile::tempdir().unwrap(); + let toml = r#" +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "0.0.0.0" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "acme" +acme_domains = ["test.local"] +acme_cache_dir = "/tmp/acme" +"#; + let config_path = dir.path().join("wildcard.toml"); + std::fs::write(&config_path, toml).unwrap(); + + let output = Command::new(binary_path()) + .arg("--config") + .arg(config_path.to_str().unwrap()) + .arg("--validate") + .arg("--allow-wildcard-bind") + .output() + .unwrap(); + assert!( + output.status.success(), + "expected exit 0 with --allow-wildcard-bind, got {}: stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); +}