From f72fe791e19e6f609b1c26de7e014764508b287b Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 11 Jun 2026 12:48:21 +0000 Subject: [PATCH] Implement config validation with all 18 rules Add comprehensive validation for StaticConfig and DynamicConfig: - ValidationError enum with thiserror for descriptive error messages - validate() function that collects ALL errors (doesn't stop at first) - All 18 validation rules from config.md implemented - OR logic for allow_wildcard_bind (config OR CLI flag) - Hostname normalization to lowercase during validation - File existence check for manual mode cert_path and key_path - Unit tests covering each validation rule with valid/invalid inputs - Updated ConfigReloadHandle to use new validate() function - Added PartialEq derives to config structs for diff_static_config --- src/config/dynamic_config.rs | 183 ++++++- src/config/mod.rs | 6 + src/config/static_config.rs | 8 +- src/config/test_fixtures.rs | 12 +- src/config/validation.rs | 990 ++++++++++++++++++++++++++++++++++- src/tls/config.rs | 30 +- 6 files changed, 1200 insertions(+), 29 deletions(-) diff --git a/src/config/dynamic_config.rs b/src/config/dynamic_config.rs index 7e2840b..21038be 100644 --- a/src/config/dynamic_config.rs +++ b/src/config/dynamic_config.rs @@ -1,7 +1,14 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; use serde::Deserialize; +use tokio::sync::Mutex; + +use super::static_config::StaticConfig; +use super::validation::validate; #[allow(dead_code)] -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, PartialEq)] pub struct DynamicConfig { pub sites: Vec, pub rate_limit: RateLimitConfig, @@ -9,7 +16,7 @@ pub struct DynamicConfig { } #[allow(dead_code)] -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, PartialEq)] pub struct SiteConfig { pub host: String, pub upstream: String, @@ -37,14 +44,182 @@ fn default_request_timeout() -> u64 { } #[allow(dead_code)] -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, PartialEq)] pub struct RateLimitConfig { pub requests_per_second: u32, pub burst: u32, } #[allow(dead_code)] -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, PartialEq)] pub struct BodyConfig { pub limit_bytes: u64, } + +#[allow(dead_code)] +pub struct ConfigReloadHandle { + config: Arc>, + static_config: StaticConfig, + reload_mutex: Mutex<()>, +} + +#[allow(dead_code)] +impl ConfigReloadHandle { + pub fn new(config: Arc>, static_config: StaticConfig) -> Self { + Self { + config, + static_config, + reload_mutex: Mutex::new(()), + } + } + + pub fn load(&self) -> Arc { + self.config.load_full() + } + + pub async fn reload( + &self, + new_static: StaticConfig, + new_dynamic: DynamicConfig, + ) -> anyhow::Result> { + let _guard = self.reload_mutex.lock().await; + + validate(&new_static, &new_dynamic, false).map_err(|errors| { + anyhow::anyhow!( + "{}", + errors + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("; ") + ) + })?; + + let changed_fields = diff_static_config(&self.static_config, &new_static); + + self.config.store(Arc::new(new_dynamic)); + + Ok(changed_fields) + } +} + +fn diff_static_config(old: &StaticConfig, new: &StaticConfig) -> Vec { + let mut changes = Vec::new(); + + if old.listeners != new.listeners { + changes.push("listeners".to_string()); + } + if old.allow_wildcard_bind != new.allow_wildcard_bind { + changes.push("allow_wildcard_bind".to_string()); + } + if old.health_check_port != new.health_check_port { + changes.push("health_check_port".to_string()); + } + if old.admin_socket_path != new.admin_socket_path { + changes.push("admin_socket_path".to_string()); + } + if old.shutdown_timeout_secs != new.shutdown_timeout_secs { + changes.push("shutdown_timeout_secs".to_string()); + } + if old.logging != new.logging { + changes.push("logging".to_string()); + } + + changes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::test_fixtures; + + #[test] + fn arcswap_swap_visible_after_reload() { + let initial = test_fixtures::test_dynamic_config(); + let config_arc = Arc::new(ArcSwap::from_pointee(initial.clone())); + let static_config = test_fixtures::test_static_config(); + let handle = ConfigReloadHandle::new(config_arc.clone(), static_config); + + let loaded = handle.load(); + assert_eq!(loaded.sites.len(), 1); + assert_eq!(loaded.rate_limit.requests_per_second, 10); + + let mut new_dynamic = initial.clone(); + new_dynamic.rate_limit.requests_per_second = 50; + new_dynamic.sites.push(SiteConfig { + host: "new.test".to_string(), + upstream: "127.0.0.1:9090".to_string(), + upstream_scheme: "http".to_string(), + upstream_connect_timeout_secs: 5, + upstream_request_timeout_secs: 60, + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(handle.reload(test_fixtures::test_static_config(), new_dynamic)) + .unwrap(); + + let loaded = handle.load(); + assert_eq!(loaded.sites.len(), 2); + assert_eq!(loaded.rate_limit.requests_per_second, 50); + } + + #[test] + fn reload_rejects_invalid_config() { + let initial = test_fixtures::test_dynamic_config(); + let config_arc = Arc::new(ArcSwap::from_pointee(initial.clone())); + let static_config = test_fixtures::test_static_config(); + let handle = ConfigReloadHandle::new(config_arc.clone(), static_config); + + let mut invalid_dynamic = initial.clone(); + invalid_dynamic.rate_limit.requests_per_second = 0; + + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = + rt.block_on(handle.reload(test_fixtures::test_static_config(), invalid_dynamic)); + assert!(result.is_err()); + + let loaded = config_arc.load(); + assert_eq!(loaded.rate_limit.requests_per_second, 10); + } + + #[tokio::test] + async fn concurrent_reload_serialization() { + let initial = test_fixtures::test_dynamic_config(); + let config_arc = Arc::new(ArcSwap::from_pointee(initial.clone())); + let static_config = test_fixtures::test_static_config(); + let handle = Arc::new(ConfigReloadHandle::new(config_arc.clone(), static_config)); + + let mut handles = Vec::new(); + for i in 1..=5u32 { + let h = handle.clone(); + let initial = initial.clone(); + handles.push(tokio::spawn(async move { + let mut dynamic = initial.clone(); + dynamic.rate_limit.requests_per_second = i * 10; + h.reload(test_fixtures::test_static_config(), dynamic).await + })); + } + + for h in handles { + h.await.unwrap().unwrap(); + } + + let loaded = config_arc.load(); + let rps = loaded.rate_limit.requests_per_second; + assert!((rps == 10) || (rps == 20) || (rps == 30) || (rps == 40) || (rps == 50)); + } + + #[test] + fn static_config_diff_detects_changes() { + let old = test_fixtures::test_static_config(); + let mut new = old.clone(); + assert!(diff_static_config(&old, &new).is_empty()); + + new.health_check_port = 8080; + new.logging.level = "debug".to_string(); + let changes = diff_static_config(&old, &new); + assert!(changes.contains(&"health_check_port".to_string())); + assert!(changes.contains(&"logging".to_string())); + assert_eq!(changes.len(), 2); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 072ddc9..cf746ca 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,3 +2,9 @@ pub mod dynamic_config; pub mod static_config; pub mod test_fixtures; pub mod validation; + +pub use dynamic_config::{ + BodyConfig, ConfigReloadHandle, DynamicConfig, RateLimitConfig, SiteConfig, +}; +pub use static_config::{ListenerConfig, LoggingConfig, StaticConfig, TlsConfig}; +pub use validation::{validate, ValidationError}; diff --git a/src/config/static_config.rs b/src/config/static_config.rs index 500dc66..444d415 100644 --- a/src/config/static_config.rs +++ b/src/config/static_config.rs @@ -1,7 +1,7 @@ use serde::Deserialize; #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct StaticConfig { pub listeners: Vec, #[serde(default)] @@ -32,7 +32,7 @@ fn default_shutdown_timeout_secs() -> u64 { } #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct ListenerConfig { pub bind_addr: String, #[serde(default = "default_http_port")] @@ -55,7 +55,7 @@ fn default_https_port() -> u16 { } #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct TlsConfig { pub mode: String, #[serde(default)] @@ -76,7 +76,7 @@ fn default_acme_directory() -> String { } #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct LoggingConfig { #[serde(default = "default_log_level")] pub level: String, diff --git a/src/config/test_fixtures.rs b/src/config/test_fixtures.rs index c29a6cc..76479cd 100644 --- a/src/config/test_fixtures.rs +++ b/src/config/test_fixtures.rs @@ -8,12 +8,12 @@ pub fn test_static_config() -> StaticConfig { http_port: 80, https_port: 443, tls: TlsConfig { - mode: "manual".to_string(), - acme_domains: vec![], - acme_cache_dir: String::new(), - acme_directory: "production".to_string(), - cert_path: "/tmp/test-cert.pem".to_string(), - key_path: "/tmp/test-key.pem".to_string(), + mode: "acme".to_string(), + acme_domains: vec!["test.local".to_string()], + acme_cache_dir: "/tmp/acme-cache".to_string(), + acme_directory: "staging".to_string(), + cert_path: String::new(), + key_path: String::new(), }, sites: vec![], }], diff --git a/src/config/validation.rs b/src/config/validation.rs index 9039dbc..50dfc47 100644 --- a/src/config/validation.rs +++ b/src/config/validation.rs @@ -1,12 +1,988 @@ -use anyhow::Result; +use std::collections::HashSet; +use std::path::Path; + +use thiserror::Error; use super::dynamic_config::DynamicConfig; use super::static_config::StaticConfig; -#[allow(dead_code)] -pub fn validate_config( - _static_config: &StaticConfig, - _dynamic_config: &DynamicConfig, -) -> Result<()> { - Ok(()) +#[derive(Debug, Error)] +pub enum ValidationError { + #[error("at least one listener must be defined")] + NoListeners, + #[error("listener {bind_addr}: bind_addr is 0.0.0.0 but allow_wildcard_bind is not enabled (config or CLI flag required)")] + WildcardBindNotAllowed { bind_addr: String }, + #[error("duplicate bind_addr:https_port combination: {bind_addr}:{https_port}")] + DuplicateHttpsBind { bind_addr: String, https_port: u16 }, + #[error("listener {bind_addr}: ACME mode requires acme_domains to be non-empty")] + AcmeDomainsEmpty { bind_addr: String }, + #[error("listener {bind_addr}: manual mode requires both cert_path and key_path")] + ManualCertMissing { bind_addr: String }, + #[error("listener {bind_addr}: cert_path '{path}' is not readable: {reason}")] + CertPathNotReadable { + bind_addr: String, + path: String, + reason: String, + }, + #[error("listener {bind_addr}: key_path '{path}' is not readable: {reason}")] + KeyPathNotReadable { + bind_addr: String, + path: String, + reason: String, + }, + #[error("site '{host}': host must be set")] + SiteHostEmpty { host: String }, + #[error("site '{host}': upstream must be set")] + SiteUpstreamEmpty { host: String }, + #[error("duplicate site host '{host}' across listeners")] + DuplicateSiteHost { host: String }, + #[error("rate_limit.requests_per_second must be > 0, got {value}")] + RequestsPerSecondZero { value: u32 }, + #[error("body.limit_bytes must be > 0, got {value}")] + BodyLimitBytesZero { value: u64 }, + #[error("duplicate bind_addr:http_port combination: {bind_addr}:{http_port}")] + DuplicateHttpBind { bind_addr: String, http_port: u16 }, + #[error( + "listener {bind_addr}: http_port ({http_port}) and https_port ({https_port}) must differ" + )] + HttpsAndHttpPortSame { + bind_addr: String, + http_port: u16, + https_port: u16, + }, + #[error("listener {bind_addr}: https_port must be 1-65535, got {https_port}")] + HttpsPortInvalid { bind_addr: String, https_port: u16 }, + #[error("listener {bind_addr}: http_port must be 0 (disabled) or 1-65535, got {http_port}")] + HttpPortInvalid { bind_addr: String, http_port: u16 }, + #[error("health_check_port {health_check_port} conflicts with listener {bind_addr}:{port}")] + HealthCheckPortConflict { + health_check_port: u16, + bind_addr: String, + port: u16, + }, + #[error("site '{host}': host must not include a port number")] + SiteHostContainsPort { host: String }, + #[error("site '{host}': invalid hostname (must be a valid DNS name, not an IP address)")] + SiteHostInvalid { host: String }, + #[error( + "site '{host}': upstream must be in host:port format with port 1-65535, got '{upstream}'" + )] + UpstreamInvalid { host: String, upstream: String }, + #[error("site '{host}': upstream_scheme must be 'http' or 'https', got '{scheme}'")] + UpstreamSchemeInvalid { host: String, scheme: String }, +} + +pub fn validate( + static_config: &StaticConfig, + dynamic_config: &DynamicConfig, + cli_allow_wildcard_bind: bool, +) -> Result<(), Vec> { + let mut errors = Vec::new(); + + let allow_wildcard = static_config.allow_wildcard_bind || cli_allow_wildcard_bind; + + if static_config.listeners.is_empty() { + errors.push(ValidationError::NoListeners); + } + + let mut https_bind_keys = HashSet::new(); + let mut http_bind_keys = HashSet::new(); + + for listener in &static_config.listeners { + if listener.bind_addr == "0.0.0.0" && !allow_wildcard { + errors.push(ValidationError::WildcardBindNotAllowed { + bind_addr: listener.bind_addr.clone(), + }); + } + + let https_key = (listener.bind_addr.as_str(), listener.https_port); + if !https_bind_keys.insert(https_key) { + errors.push(ValidationError::DuplicateHttpsBind { + bind_addr: listener.bind_addr.clone(), + https_port: listener.https_port, + }); + } + + if listener.http_port > 0 { + let http_key = (listener.bind_addr.as_str(), listener.http_port); + if !http_bind_keys.insert(http_key) { + errors.push(ValidationError::DuplicateHttpBind { + bind_addr: listener.bind_addr.clone(), + http_port: listener.http_port, + }); + } + } + + if listener.https_port == 0 { + errors.push(ValidationError::HttpsPortInvalid { + bind_addr: listener.bind_addr.clone(), + https_port: listener.https_port, + }); + } + + if listener.http_port > 0 && listener.http_port == listener.https_port { + errors.push(ValidationError::HttpsAndHttpPortSame { + bind_addr: listener.bind_addr.clone(), + http_port: listener.http_port, + https_port: listener.https_port, + }); + } + + match listener.tls.mode.as_str() { + "acme" => { + if listener.tls.acme_domains.is_empty() { + errors.push(ValidationError::AcmeDomainsEmpty { + bind_addr: listener.bind_addr.clone(), + }); + } + } + "manual" => { + let cert_empty = listener.tls.cert_path.is_empty(); + let key_empty = listener.tls.key_path.is_empty(); + if cert_empty || key_empty { + errors.push(ValidationError::ManualCertMissing { + bind_addr: listener.bind_addr.clone(), + }); + } else { + let cert_path = Path::new(&listener.tls.cert_path); + if !cert_path.exists() { + errors.push(ValidationError::CertPathNotReadable { + bind_addr: listener.bind_addr.clone(), + path: listener.tls.cert_path.clone(), + reason: "file does not exist".to_string(), + }); + } + let key_path = Path::new(&listener.tls.key_path); + if !key_path.exists() { + errors.push(ValidationError::KeyPathNotReadable { + bind_addr: listener.bind_addr.clone(), + path: listener.tls.key_path.clone(), + reason: "file does not exist".to_string(), + }); + } + } + } + _ => {} + } + } + + if static_config.health_check_port > 0 { + for listener in &static_config.listeners { + if static_config.health_check_port == listener.https_port { + errors.push(ValidationError::HealthCheckPortConflict { + health_check_port: static_config.health_check_port, + bind_addr: listener.bind_addr.clone(), + port: listener.https_port, + }); + } + if listener.http_port > 0 && static_config.health_check_port == listener.http_port { + errors.push(ValidationError::HealthCheckPortConflict { + health_check_port: static_config.health_check_port, + bind_addr: listener.bind_addr.clone(), + port: listener.http_port, + }); + } + } + } + + let mut site_hosts: HashSet = HashSet::new(); + + for listener in &static_config.listeners { + for site in &listener.sites { + if site.host.is_empty() { + errors.push(ValidationError::SiteHostEmpty { + host: String::new(), + }); + } + + if site.upstream.is_empty() { + errors.push(ValidationError::SiteUpstreamEmpty { + host: site.host.clone(), + }); + } + + let normalized_host = site.host.to_lowercase(); + + if !site_hosts.insert(normalized_host.clone()) { + errors.push(ValidationError::DuplicateSiteHost { + host: normalized_host, + }); + } + + if site.host.contains(':') { + errors.push(ValidationError::SiteHostContainsPort { + host: site.host.clone(), + }); + } + + if !is_valid_hostname(&site.host) { + errors.push(ValidationError::SiteHostInvalid { + host: site.host.clone(), + }); + } + + if !site.upstream.is_empty() && !is_valid_upstream(&site.upstream) { + errors.push(ValidationError::UpstreamInvalid { + host: site.host.clone(), + upstream: site.upstream.clone(), + }); + } + + if site.upstream_scheme != "http" && site.upstream_scheme != "https" { + errors.push(ValidationError::UpstreamSchemeInvalid { + host: site.host.clone(), + scheme: site.upstream_scheme.clone(), + }); + } + } + } + + if dynamic_config.rate_limit.requests_per_second == 0 { + errors.push(ValidationError::RequestsPerSecondZero { value: 0 }); + } + + if dynamic_config.body.limit_bytes == 0 { + errors.push(ValidationError::BodyLimitBytesZero { value: 0 }); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +fn is_valid_hostname(host: &str) -> bool { + if host.is_empty() { + return false; + } + if host.contains(':') { + return false; + } + if host.parse::().is_ok() { + return false; + } + if host.starts_with('-') || host.ends_with('-') { + return false; + } + if host.contains('.') { + for label in host.split('.') { + if label.is_empty() { + return false; + } + if label.starts_with('-') || label.ends_with('-') { + return false; + } + if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return false; + } + } + true + } else { + host.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + } +} + +fn is_valid_upstream(upstream: &str) -> bool { + if let Some(idx) = upstream.rfind(':') { + let host_part = &upstream[..idx]; + let port_str = &upstream[idx + 1..]; + if host_part.is_empty() { + return false; + } + if upstream.starts_with("http://") || upstream.starts_with("https://") { + return false; + } + let port: u16 = match port_str.parse() { + Ok(p) => p, + Err(_) => return false, + }; + port != 0 + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::dynamic_config::{BodyConfig, DynamicConfig, RateLimitConfig, SiteConfig}; + use crate::config::static_config::{ListenerConfig, LoggingConfig, StaticConfig, TlsConfig}; + use std::fs; + + fn valid_static_config() -> StaticConfig { + StaticConfig { + listeners: vec![ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 80, + https_port: 443, + tls: TlsConfig { + mode: "manual".to_string(), + acme_domains: vec![], + acme_cache_dir: String::new(), + acme_directory: "production".to_string(), + cert_path: String::new(), + key_path: String::new(), + }, + sites: vec![], + }], + allow_wildcard_bind: false, + health_check_port: 9900, + admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(), + shutdown_timeout_secs: 30, + logging: LoggingConfig::default(), + } + } + + fn valid_dynamic_config() -> DynamicConfig { + DynamicConfig { + sites: vec![SiteConfig { + host: "test.local".to_string(), + upstream: "127.0.0.1:8080".to_string(), + upstream_scheme: "http".to_string(), + upstream_connect_timeout_secs: 5, + upstream_request_timeout_secs: 60, + }], + rate_limit: RateLimitConfig { + requests_per_second: 10, + burst: 20, + }, + body: BodyConfig { + limit_bytes: 104857600, + }, + } + } + + fn make_static_with_sites(sites: Vec, tls: TlsConfig) -> StaticConfig { + StaticConfig { + listeners: vec![ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 80, + https_port: 443, + tls, + sites, + }], + allow_wildcard_bind: false, + health_check_port: 9900, + admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(), + shutdown_timeout_secs: 30, + logging: LoggingConfig::default(), + } + } + + fn make_acme_tls() -> TlsConfig { + TlsConfig { + mode: "acme".to_string(), + acme_domains: vec!["test.local".to_string()], + acme_cache_dir: "/tmp/acme-cache".to_string(), + acme_directory: "production".to_string(), + cert_path: String::new(), + key_path: String::new(), + } + } + + fn make_manual_tls(cert: &str, key: &str) -> TlsConfig { + TlsConfig { + mode: "manual".to_string(), + acme_domains: vec![], + acme_cache_dir: String::new(), + acme_directory: "production".to_string(), + cert_path: cert.to_string(), + key_path: key.to_string(), + } + } + + #[test] + fn rule1_no_listeners() { + let mut config = valid_static_config(); + config.listeners = vec![]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::NoListeners))); + } + + #[test] + fn rule2_wildcard_bind_rejected() { + let mut config = valid_static_config(); + config.listeners[0].bind_addr = "0.0.0.0".to_string(); + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::WildcardBindNotAllowed { .. }))); + } + + #[test] + fn rule2_wildcard_bind_allowed_by_config_flag() { + let mut config = valid_static_config(); + config.listeners[0].bind_addr = "0.0.0.0".to_string(); + config.allow_wildcard_bind = true; + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_ok()); + } + + #[test] + fn rule2_wildcard_bind_allowed_by_cli_flag() { + let mut config = valid_static_config(); + config.listeners[0].bind_addr = "0.0.0.0".to_string(); + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, true); + assert!(result.is_ok()); + } + + #[test] + fn rule2_wildcard_bind_or_logic() { + let mut config = valid_static_config(); + config.listeners[0].bind_addr = "0.0.0.0".to_string(); + config.listeners[0].tls = make_acme_tls(); + config.allow_wildcard_bind = false; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, true); + assert!(result.is_ok()); + } + + #[test] + fn rule3_duplicate_https_bind() { + let mut config = valid_static_config(); + config.listeners = vec![ + ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 80, + https_port: 443, + tls: make_acme_tls(), + sites: vec![], + }, + ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 8080, + https_port: 443, + tls: make_acme_tls(), + sites: vec![], + }, + ]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::DuplicateHttpsBind { .. }))); + } + + #[test] + fn rule4_acme_domains_empty() { + let mut config = valid_static_config(); + config.listeners[0].tls = TlsConfig { + mode: "acme".to_string(), + acme_domains: vec![], + acme_cache_dir: "/tmp/cache".to_string(), + acme_directory: "production".to_string(), + cert_path: String::new(), + key_path: String::new(), + }; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::AcmeDomainsEmpty { .. }))); + } + + #[test] + fn rule5_manual_cert_missing() { + let mut config = valid_static_config(); + config.listeners[0].tls = TlsConfig { + mode: "manual".to_string(), + acme_domains: vec![], + acme_cache_dir: String::new(), + acme_directory: "production".to_string(), + cert_path: String::new(), + key_path: String::new(), + }; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::ManualCertMissing { .. }))); + } + + #[test] + fn rule5_cert_path_not_readable() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_manual_tls("/nonexistent/cert.pem", "/nonexistent/key.pem"); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::CertPathNotReadable { .. }))); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::KeyPathNotReadable { .. }))); + } + + #[test] + fn rule5_cert_path_readable() { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + fs::write(&cert_path, "cert").unwrap(); + fs::write(&key_path, "key").unwrap(); + + let mut config = valid_static_config(); + config.listeners[0].tls = + make_manual_tls(cert_path.to_str().unwrap(), key_path.to_str().unwrap()); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_ok()); + } + + #[test] + fn rule6_site_host_empty() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: String::new(), + upstream: "127.0.0.1:8080".to_string(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::SiteHostEmpty { .. }))); + } + + #[test] + fn rule6_site_upstream_empty() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".to_string(), + upstream: String::new(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::SiteUpstreamEmpty { .. }))); + } + + #[test] + fn rule7_duplicate_site_host() { + let mut config = valid_static_config(); + config.listeners = vec![ + ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 80, + https_port: 443, + tls: make_acme_tls(), + sites: vec![SiteConfig { + host: "test.local".to_string(), + upstream: "127.0.0.1:8080".to_string(), + upstream_scheme: "http".to_string(), + upstream_connect_timeout_secs: 5, + upstream_request_timeout_secs: 60, + }], + }, + ListenerConfig { + bind_addr: "127.0.0.2".to_string(), + http_port: 80, + https_port: 443, + tls: make_acme_tls(), + sites: vec![SiteConfig { + host: "test.local".to_string(), + upstream: "127.0.0.1:9090".to_string(), + upstream_scheme: "http".to_string(), + upstream_connect_timeout_secs: 5, + upstream_request_timeout_secs: 60, + }], + }, + ]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::DuplicateSiteHost { .. }))); + } + + #[test] + fn rule8_requests_per_second_zero() { + let config = valid_static_config(); + let mut dynamic = valid_dynamic_config(); + dynamic.rate_limit.requests_per_second = 0; + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::RequestsPerSecondZero { .. }))); + } + + #[test] + fn rule9_body_limit_bytes_zero() { + let config = valid_static_config(); + let mut dynamic = valid_dynamic_config(); + dynamic.body.limit_bytes = 0; + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::BodyLimitBytesZero { .. }))); + } + + #[test] + fn rule10_duplicate_http_bind() { + let mut config = valid_static_config(); + config.listeners = vec![ + ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 80, + https_port: 443, + tls: make_acme_tls(), + sites: vec![], + }, + ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 80, + https_port: 8443, + tls: make_acme_tls(), + sites: vec![], + }, + ]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::DuplicateHttpBind { .. }))); + } + + #[test] + fn rule11_http_and_https_port_same() { + let mut config = valid_static_config(); + config.listeners[0].http_port = 443; + config.listeners[0].https_port = 443; + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::HttpsAndHttpPortSame { .. }))); + } + + #[test] + fn rule12_https_port_zero() { + let mut config = valid_static_config(); + config.listeners[0].https_port = 0; + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::HttpsPortInvalid { .. }))); + } + + #[test] + fn rule13_http_port_disabled_valid() { + let mut config = valid_static_config(); + config.listeners[0].http_port = 0; + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_ok()); + } + + #[test] + fn rule14_health_check_port_conflict() { + let mut config = valid_static_config(); + config.health_check_port = 443; + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::HealthCheckPortConflict { .. }))); + } + + #[test] + fn rule14_health_check_port_conflict_with_http() { + let mut config = valid_static_config(); + config.health_check_port = 80; + config.listeners[0].tls = make_acme_tls(); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::HealthCheckPortConflict { .. }))); + } + + #[test] + fn rule15_site_host_contains_port() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: "test.local:443".to_string(), + upstream: "127.0.0.1:8080".to_string(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::SiteHostContainsPort { .. }))); + } + + #[test] + fn rule16_site_host_is_ip() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: "127.0.0.1".to_string(), + upstream: "127.0.0.1:8080".to_string(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::SiteHostInvalid { .. }))); + } + + #[test] + fn rule16_hostname_normalized_lowercase() { + let mut config = valid_static_config(); + config.listeners = vec![ + ListenerConfig { + bind_addr: "127.0.0.1".to_string(), + http_port: 80, + https_port: 443, + tls: make_acme_tls(), + sites: vec![SiteConfig { + host: "Test.Local".to_string(), + upstream: "127.0.0.1:8080".to_string(), + ..valid_dynamic_config().sites[0].clone() + }], + }, + ListenerConfig { + bind_addr: "127.0.0.2".to_string(), + http_port: 80, + https_port: 443, + tls: make_acme_tls(), + sites: vec![SiteConfig { + host: "test.local".to_string(), + upstream: "127.0.0.1:9090".to_string(), + ..valid_dynamic_config().sites[0].clone() + }], + }, + ]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::DuplicateSiteHost { .. }))); + } + + #[test] + fn rule17_upstream_missing_port() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".to_string(), + upstream: "gitea".to_string(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::UpstreamInvalid { .. }))); + } + + #[test] + fn rule17_upstream_with_scheme() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".to_string(), + upstream: "http://gitea:3000".to_string(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::UpstreamInvalid { .. }))); + } + + #[test] + fn rule17_upstream_port_zero() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".to_string(), + upstream: "gitea:0".to_string(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::UpstreamInvalid { .. }))); + } + + #[test] + fn rule18_upstream_scheme_invalid() { + let mut config = valid_static_config(); + config.listeners[0].tls = make_acme_tls(); + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".to_string(), + upstream: "127.0.0.1:8080".to_string(), + upstream_scheme: "ftp".to_string(), + ..valid_dynamic_config().sites[0].clone() + }]; + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::UpstreamSchemeInvalid { .. }))); + } + + #[test] + fn valid_config_passes() { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + fs::write(&cert_path, "cert").unwrap(); + fs::write(&key_path, "key").unwrap(); + + let config = make_static_with_sites( + vec![SiteConfig { + host: "test.local".to_string(), + upstream: "127.0.0.1:8080".to_string(), + upstream_scheme: "http".to_string(), + upstream_connect_timeout_secs: 5, + upstream_request_timeout_secs: 60, + }], + make_manual_tls(cert_path.to_str().unwrap(), key_path.to_str().unwrap()), + ); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_ok()); + } + + #[test] + fn collects_all_errors() { + let config = StaticConfig { + listeners: vec![], + allow_wildcard_bind: false, + health_check_port: 9900, + admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(), + shutdown_timeout_secs: 30, + logging: LoggingConfig::default(), + }; + let mut dynamic = valid_dynamic_config(); + dynamic.rate_limit.requests_per_second = 0; + dynamic.body.limit_bytes = 0; + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors.len() >= 3); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::NoListeners))); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::RequestsPerSecondZero { .. }))); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::BodyLimitBytesZero { .. }))); + } + + #[test] + fn rule5_only_cert_missing() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("key.pem"); + fs::write(&key_path, "key").unwrap(); + + let mut config = valid_static_config(); + config.listeners[0].tls = + make_manual_tls("/nonexistent/cert.pem", key_path.to_str().unwrap()); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::CertPathNotReadable { .. }))); + assert!(!errors + .iter() + .any(|e| matches!(e, ValidationError::KeyPathNotReadable { .. }))); + } + + #[test] + fn rule5_only_key_missing() { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + fs::write(&cert_path, "cert").unwrap(); + + let mut config = valid_static_config(); + config.listeners[0].tls = + make_manual_tls(cert_path.to_str().unwrap(), "/nonexistent/key.pem"); + let dynamic = valid_dynamic_config(); + let result = validate(&config, &dynamic, false); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(!errors + .iter() + .any(|e| matches!(e, ValidationError::CertPathNotReadable { .. }))); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::KeyPathNotReadable { .. }))); + } } diff --git a/src/tls/config.rs b/src/tls/config.rs index a6c917e..1b705b7 100644 --- a/src/tls/config.rs +++ b/src/tls/config.rs @@ -207,13 +207,27 @@ mod tests { .map(|cs| format!("{cs:?}")) .collect(); - assert!(cipher_suites.iter().any(|cs| cs.contains("AES_256_GCM_SHA384"))); - assert!(cipher_suites.iter().any(|cs| cs.contains("AES_128_GCM_SHA256"))); - assert!(cipher_suites.iter().any(|cs| cs.contains("CHACHA20_POLY1305_SHA256"))); - assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"))); - assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"))); - assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_AES_256_GCM_SHA384"))); - assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_AES_128_GCM_SHA256"))); + assert!(cipher_suites + .iter() + .any(|cs| cs.contains("AES_256_GCM_SHA384"))); + assert!(cipher_suites + .iter() + .any(|cs| cs.contains("AES_128_GCM_SHA256"))); + assert!(cipher_suites + .iter() + .any(|cs| cs.contains("CHACHA20_POLY1305_SHA256"))); + assert!(cipher_suites + .iter() + .any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"))); + assert!(cipher_suites + .iter() + .any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"))); + assert!(cipher_suites + .iter() + .any(|cs| cs.contains("ECDHE_RSA_WITH_AES_256_GCM_SHA384"))); + assert!(cipher_suites + .iter() + .any(|cs| cs.contains("ECDHE_RSA_WITH_AES_128_GCM_SHA256"))); } #[test] @@ -310,4 +324,4 @@ mod tests { let result = load_private_key("/nonexistent/path/key.pem"); assert!(result.is_err()); } -} \ No newline at end of file +}