use anyhow::{Context, Result, bail}; use std::path::PathBuf; use tonic::transport::{Certificate, Identity, ServerTlsConfig}; use tracing::{info, warn}; const DEFAULT_PKI_DIR: &str = "/etc/lesavka/pki"; pub fn server_tls_config() -> Result> { let cert_path = env_path("LESAVKA_TLS_CERT").unwrap_or_else(|| default_path("server.crt")); let key_path = env_path("LESAVKA_TLS_KEY").unwrap_or_else(|| default_path("server.key")); let require_tls = bool_env("LESAVKA_REQUIRE_TLS"); if !cert_path.exists() || !key_path.exists() { if require_tls { bail!( "LESAVKA_REQUIRE_TLS=1 but TLS identity files are missing: cert={} key={}", cert_path.display(), key_path.display() ); } warn!( cert = %cert_path.display(), key = %key_path.display(), "🔓 TLS identity not present; serving plaintext gRPC for local/dev use" ); return Ok(None); } let cert = std::fs::read(&cert_path) .with_context(|| format!("reading TLS server certificate {}", cert_path.display()))?; let key = std::fs::read(&key_path) .with_context(|| format!("reading TLS server key {}", key_path.display()))?; let mut tls = ServerTlsConfig::new().identity(Identity::from_pem(cert, key)); let client_ca_path = env_path("LESAVKA_TLS_CLIENT_CA").unwrap_or_else(|| default_path("ca.crt")); if client_ca_path.exists() { let client_ca = std::fs::read(&client_ca_path) .with_context(|| format!("reading TLS client CA {}", client_ca_path.display()))?; tls = tls.client_ca_root(Certificate::from_pem(client_ca)); if bool_env("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL") { tls = tls.client_auth_optional(true); warn!("🔐 TLS enabled with optional client certificates"); } else { info!("🔐 TLS enabled with required client certificate authentication"); } } else { warn!( ca = %client_ca_path.display(), "🔐 TLS enabled without client certificate authentication; install a client CA for production" ); } Ok(Some(tls)) } fn env_path(name: &str) -> Option { std::env::var_os(name) .filter(|value| !value.is_empty()) .map(PathBuf::from) } fn default_path(file_name: &str) -> PathBuf { PathBuf::from(DEFAULT_PKI_DIR).join(file_name) } fn bool_env(name: &str) -> bool { std::env::var(name) .map(|value| { let value = value.trim().to_ascii_lowercase(); !value.is_empty() && value != "0" && value != "false" && value != "no" }) .unwrap_or(false) } #[cfg(test)] mod tests { use super::{bool_env, default_path, env_path, server_tls_config}; use temp_env::with_vars; use tempfile::tempdir; #[test] fn missing_identity_is_allowed_unless_tls_is_required() { with_vars( [ ("LESAVKA_REQUIRE_TLS", None::<&str>), ("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")), ("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")), ], || { assert!( server_tls_config() .expect("optional tls should not error") .is_none() ) }, ); } #[test] fn require_tls_errors_when_identity_is_missing() { with_vars( [ ("LESAVKA_REQUIRE_TLS", Some("1")), ("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")), ("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")), ], || { let err = server_tls_config().expect_err("required tls should fail"); assert!(err.to_string().contains("LESAVKA_REQUIRE_TLS=1")); }, ); } #[test] fn helpers_parse_paths_and_boolean_overrides() { with_vars( [ ("LESAVKA_TLS_CERT", Some("/tmp/server.crt")), ("LESAVKA_REQUIRE_TLS", Some("yes")), ], || { assert_eq!( env_path("LESAVKA_TLS_CERT").unwrap(), std::path::PathBuf::from("/tmp/server.crt") ); assert!(bool_env("LESAVKA_REQUIRE_TLS")); }, ); with_vars( [ ("LESAVKA_TLS_CERT", Some("")), ("LESAVKA_REQUIRE_TLS", Some("false")), ], || { assert!(env_path("LESAVKA_TLS_CERT").is_none()); assert!(!bool_env("LESAVKA_REQUIRE_TLS")); assert_eq!( default_path("server.crt"), std::path::PathBuf::from("/etc/lesavka/pki/server.crt") ); }, ); } #[test] fn tls_identity_can_load_with_and_without_client_ca() { let dir = tempdir().expect("tls dir"); let cert = dir.path().join("server.crt"); let key = dir.path().join("server.key"); let ca = dir.path().join("ca.crt"); std::fs::write(&cert, b"not a real cert").expect("cert"); std::fs::write(&key, b"not a real key").expect("key"); std::fs::write(&ca, b"not a real ca").expect("ca"); with_vars( [ ("LESAVKA_REQUIRE_TLS", Some("1")), ("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())), ("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())), ("LESAVKA_TLS_CLIENT_CA", Some(ca.to_string_lossy().as_ref())), ("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("1")), ], || { assert!(server_tls_config().expect("tls config").is_some()); }, ); with_vars( [ ("LESAVKA_REQUIRE_TLS", Some("1")), ("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())), ("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())), ( "LESAVKA_TLS_CLIENT_CA", Some(dir.path().join("missing-ca.crt").to_string_lossy().as_ref()), ), ("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("0")), ], || { assert!( server_tls_config() .expect("tls config without ca") .is_some() ); }, ); } #[test] fn existing_but_unreadable_identity_reports_context() { let dir = tempdir().expect("tls dir"); let key = dir.path().join("server.key"); std::fs::write(&key, b"not a real key").expect("key"); with_vars( [ ("LESAVKA_REQUIRE_TLS", Some("1")), ( "LESAVKA_TLS_CERT", Some(dir.path().to_string_lossy().as_ref()), ), ("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())), ], || { let err = server_tls_config().expect_err("directory cert should fail"); assert!(err.to_string().contains("reading TLS server certificate")); }, ); } }