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
This commit is contained in:
2026-06-11 13:12:28 +00:00
parent 2791070971
commit d89ab71f85
5 changed files with 553 additions and 7 deletions

387
src/cli.rs Normal file
View File

@@ -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<I, S>(args: I) -> Cli
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
Cli::parse_from(args)
}
#[derive(Debug, serde::Deserialize)]
struct RawConfig {
#[serde(default)]
listeners: Vec<crate::config::static_config::ListenerConfig>,
#[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<LoadedConfig> {
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::<Vec<_>>()
.join("\n")
)
})?;
Ok(LoadedConfig {
static_config,
dynamic_config,
allow_wildcard_bind,
})
}
fn collect_sites(static_config: &StaticConfig) -> Vec<crate::config::dynamic_config::SiteConfig> {
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);
}
}

View File

@@ -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
}

View File

@@ -1,4 +1,5 @@
pub mod admin;
pub mod cli;
pub mod config;
pub mod health;
pub mod logging;

View File

@@ -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);
}
}
}

View File

@@ -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)
);
}