diff --git a/src/config/static_config.rs b/src/config/static_config.rs index 9d7c251..c389a64 100644 --- a/src/config/static_config.rs +++ b/src/config/static_config.rs @@ -62,6 +62,8 @@ pub struct TlsConfig { #[serde(default = "default_acme_directory")] pub acme_directory: String, #[serde(default)] + pub acme_contact: String, + #[serde(default)] pub cert_path: String, #[serde(default)] pub key_path: String, diff --git a/src/config/test_fixtures.rs b/src/config/test_fixtures.rs index 1bb6e3f..7f5c634 100644 --- a/src/config/test_fixtures.rs +++ b/src/config/test_fixtures.rs @@ -12,6 +12,7 @@ pub fn test_static_config() -> StaticConfig { acme_domains: vec!["test.local".to_string()], acme_cache_dir: "/tmp/acme-cache".to_string(), acme_directory: "staging".to_string(), + acme_contact: "mailto:admin@test.local".to_string(), cert_path: String::new(), key_path: String::new(), }, diff --git a/src/config/validation.rs b/src/config/validation.rs index 8ec44d8..03edc05 100644 --- a/src/config/validation.rs +++ b/src/config/validation.rs @@ -70,6 +70,8 @@ pub enum ValidationError { UpstreamInvalid { host: String, upstream: String }, #[error("site '{host}': upstream_scheme must be 'http' or 'https', got '{scheme}'")] UpstreamSchemeInvalid { host: String, scheme: String }, + #[error("listener {bind_addr}: ACME mode requires acme_contact to be a valid mailto: URI (e.g., \"mailto:admin@example.com\")")] + AcmeContactInvalid { bind_addr: String }, } pub fn validate( @@ -142,6 +144,12 @@ pub fn validate( bind_addr: listener.bind_addr.clone(), }); } + let contact = &listener.tls.acme_contact; + if contact.is_empty() || !contact.starts_with("mailto:") { + errors.push(ValidationError::AcmeContactInvalid { + bind_addr: listener.bind_addr.clone(), + }); + } } "manual" => { let cert_empty = listener.tls.cert_path.is_empty(); @@ -331,6 +339,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: String::new(), acme_directory: "production".to_string(), + acme_contact: String::new(), cert_path: String::new(), key_path: String::new(), }, @@ -386,6 +395,7 @@ mod tests { acme_domains: vec!["test.local".to_string()], acme_cache_dir: "/tmp/acme-cache".to_string(), acme_directory: "production".to_string(), + acme_contact: "mailto:admin@example.com".to_string(), cert_path: String::new(), key_path: String::new(), } @@ -397,6 +407,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: String::new(), acme_directory: "production".to_string(), + acme_contact: String::new(), cert_path: cert.to_string(), key_path: key.to_string(), } @@ -497,6 +508,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: "/tmp/cache".to_string(), acme_directory: "production".to_string(), + acme_contact: "mailto:admin@example.com".to_string(), cert_path: String::new(), key_path: String::new(), }; @@ -517,6 +529,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: String::new(), acme_directory: "production".to_string(), + acme_contact: String::new(), cert_path: String::new(), key_path: String::new(), }; @@ -917,6 +930,80 @@ mod tests { .any(|e| matches!(e, ValidationError::UpstreamSchemeInvalid { .. }))); } + #[test] + fn rule19_acme_contact_empty() { + let mut config = valid_static_config(); + config.listeners[0].tls = TlsConfig { + mode: "acme".to_string(), + acme_domains: vec!["test.local".to_string()], + acme_cache_dir: "/tmp/cache".to_string(), + acme_directory: "production".to_string(), + acme_contact: String::new(), + cert_path: String::new(), + key_path: String::new(), + }; + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".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::AcmeContactInvalid { .. }))); + } + + #[test] + fn rule19_acme_contact_not_mailto() { + let mut config = valid_static_config(); + config.listeners[0].tls = TlsConfig { + mode: "acme".to_string(), + acme_domains: vec!["test.local".to_string()], + acme_cache_dir: "/tmp/cache".to_string(), + acme_directory: "production".to_string(), + acme_contact: "admin@example.com".to_string(), + cert_path: String::new(), + key_path: String::new(), + }; + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".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::AcmeContactInvalid { .. }))); + } + + #[test] + fn rule19_acme_contact_valid_mailto() { + let mut config = valid_static_config(); + config.listeners[0].tls = TlsConfig { + mode: "acme".to_string(), + acme_domains: vec!["test.local".to_string()], + acme_cache_dir: "/tmp/cache".to_string(), + acme_directory: "production".to_string(), + acme_contact: "mailto:admin@example.com".to_string(), + cert_path: String::new(), + key_path: String::new(), + }; + config.listeners[0].sites = vec![SiteConfig { + host: "test.local".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_ok()); + } + #[test] fn valid_config_passes() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index 7c96d4c..4e80fac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -165,11 +165,7 @@ async fn run_server(loaded_config: cli::LoadedConfig, config_path: &str) -> Resu "Manual TLS configured" ); } - TlsMode::Acme { - default_config, - challenge_config: _, - resolver: _, - } => { + TlsMode::Acme { default_config, .. } => { let acceptor = TlsAcceptor::from(default_config); tls_acceptors.push(acceptor); info!( diff --git a/src/tls/acceptor.rs b/src/tls/acceptor.rs index 4208a74..4f05eae 100644 --- a/src/tls/acceptor.rs +++ b/src/tls/acceptor.rs @@ -30,27 +30,12 @@ fn build_acme_server_config( Ok(Arc::new(config)) } -#[allow(dead_code)] -fn build_acme_challenge_config( - resolver: Arc, -) -> Arc { - let provider = crypto_provider(); - let mut config = ServerConfig::builder_with_provider(provider) - .with_protocol_versions(&[&TLS12, &TLS13]) - .expect("valid protocol versions") - .with_no_client_auth() - .with_cert_resolver(resolver); - config.alpn_protocols = vec![ACME_TLS_ALPN_01.to_vec()]; - Arc::new(config) -} - #[allow(dead_code)] #[derive(Debug)] pub enum TlsMode { Manual(Arc), Acme { default_config: Arc, - challenge_config: Arc, resolver: Arc, }, } @@ -83,13 +68,12 @@ pub fn setup_tls(tls_config: &TlsConfig) -> Result { domains: tls_config.acme_domains.clone(), cache_dir: tls_config.acme_cache_dir.clone().into(), directory: tls_config.acme_directory.clone(), - contact: vec![], + contact: vec![tls_config.acme_contact.clone()], }; let super::acme::AcmeTlsSetup { resolver, state } = acme_tls_config.setup()?; let default_config = build_acme_server_config(resolver.clone())?; - let challenge_config = build_acme_challenge_config(resolver.clone()); spawn_acme_state(state, tls_config.acme_domains.clone()); @@ -100,7 +84,6 @@ pub fn setup_tls(tls_config: &TlsConfig) -> Result { Ok(TlsMode::Acme { default_config, - challenge_config, resolver, }) } @@ -142,14 +125,6 @@ mod tests { assert!(config.alpn_protocols.contains(&ACME_TLS_ALPN_01.to_vec())); } - #[test] - fn test_build_acme_challenge_config() { - let resolver = make_test_resolver(); - let config = build_acme_challenge_config(resolver); - assert_eq!(config.alpn_protocols.len(), 1); - assert_eq!(config.alpn_protocols[0], ACME_TLS_ALPN_01); - } - #[test] fn test_setup_tls_manual_missing_cert_path() { let tls_config = TlsConfig { @@ -157,6 +132,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: String::new(), acme_directory: "production".to_string(), + acme_contact: String::new(), cert_path: String::new(), key_path: "/some/key.pem".to_string(), }; @@ -173,6 +149,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: String::new(), acme_directory: "production".to_string(), + acme_contact: String::new(), cert_path: "/some/cert.pem".to_string(), key_path: String::new(), }; @@ -189,6 +166,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: "/tmp/cache".to_string(), acme_directory: "staging".to_string(), + acme_contact: "mailto:admin@example.com".to_string(), cert_path: String::new(), key_path: String::new(), }; @@ -205,6 +183,7 @@ mod tests { acme_domains: vec!["example.com".to_string()], acme_cache_dir: String::new(), acme_directory: "staging".to_string(), + acme_contact: "mailto:admin@example.com".to_string(), cert_path: String::new(), key_path: String::new(), }; @@ -221,6 +200,7 @@ mod tests { acme_domains: vec![], acme_cache_dir: String::new(), acme_directory: "production".to_string(), + acme_contact: String::new(), cert_path: String::new(), key_path: String::new(), }; @@ -229,4 +209,4 @@ mod tests { let err = result.unwrap_err().to_string(); assert!(err.contains("unknown TLS mode")); } -} +} \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs index b677853..4ef164d 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -271,6 +271,7 @@ fn make_redirect_listener_config( acme_domains: vec![], acme_cache_dir: String::new(), acme_directory: "production".to_string(), + acme_contact: String::new(), cert_path: String::new(), key_path: String::new(), },