use std::net::IpAddr; use rcgen::string::Ia5String; use serde::{Deserialize, Deserializer, Serialize}; use crate::config::settings::{Validate, ValidationError}; /// TLS certificate settings #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CertificateSettings { #[serde(default = "default_cert_folder")] pub cert_dir: String, #[serde( default, serialize_with = "serialize_ia5string_vec", deserialize_with = "deserialize_ia5string_vec" )] pub san_dns: Vec, #[serde(default)] pub san_ip: Vec, #[serde(default)] pub cert_path: Option, #[serde(default)] pub key_path: Option, } impl CertificateSettings { pub fn cert_path(&self) -> Option { self.cert_path .as_ref() .map(|p| format!("{}/{}", self.cert_dir, p)) } pub fn key_path(&self) -> Option { self.key_path .as_ref() .map(|p| format!("{}/{}", self.cert_dir, p)) } } impl Validate for CertificateSettings { fn validate(&self) -> Result<(), ValidationError> { let base_path = std::path::Path::new(&self.cert_dir); if !base_path.exists() { // create the cert directory if it doesn't exist std::fs::create_dir_all(base_path).map_err(|e| { format!( "Failed to create certificate directory {:?}: {}", base_path, e ) })?; } let cert_path = self.cert_path.as_ref().map(|p| base_path.join(p)); let key_path = self.key_path.as_ref().map(|p| base_path.join(p)); if (cert_path.is_some() && key_path.is_none()) || (cert_path.is_none() && key_path.is_some()) { return Err("Both certificate and key paths must be provided for TLS".into()); } if let (Some(cert_path), Some(key_path)) = (&cert_path, &key_path) { if !std::path::Path::new(cert_path).exists() { return Err(format!("Certificate file not found: {:?}", cert_path)); } if !std::path::Path::new(key_path).exists() { return Err(format!("Key file not found: {:?}", key_path)); } } // validate for SAN entries - must be valid DNS names or IP addresses for dns in &self.san_dns { if dns.to_string().is_empty() { return Err("SAN DNS entries cannot be empty".into()); } } for ip in &self.san_ip { if ip.is_unspecified() { return Err("SAN IP entries cannot be unspecified".into()); } } // require at least one SAN entry for the generated certificate if self.san_dns.is_empty() && self.san_ip.is_empty() { return Err( "At least one SAN entry (DNS or IP) must be provided for the certificate".into(), ); } Ok(()) } } fn default_cert_folder() -> String { "./certs".into() } fn deserialize_ia5string_vec<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let vec = Vec::::deserialize(deserializer)?; vec.into_iter() .map(|s| Ia5String::try_from(s).map_err(serde::de::Error::custom)) .collect() } fn serialize_ia5string_vec(vec: &Vec, serializer: S) -> Result where S: serde::Serializer, { let string_vec: Vec = vec.iter().map(|ia5| ia5.to_string()).collect(); string_vec.serialize(serializer) } #[cfg(test)] mod tests { use std::{ fs, net::{IpAddr, Ipv4Addr}, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; use super::*; #[test] fn test_esnure_send_and_sync() { fn assert_send_sync() {} assert_send_sync::(); } fn make_temp_dir(prefix: &str) -> PathBuf { let ts = SystemTime::now().duration_since(UNIX_EPOCH); assert!(ts.is_ok()); let ts = ts.unwrap_or_default(); let path = std::env::temp_dir().join(format!( "{}_{}_{}", prefix, std::process::id(), ts.as_nanos() )); let created = fs::create_dir_all(&path); assert!(created.is_ok()); path } #[test] fn certificate_paths_include_cert_dir() { let cert = CertificateSettings { cert_dir: "./certs".to_string(), san_dns: Vec::new(), san_ip: Vec::new(), cert_path: Some("server.crt".to_string()), key_path: Some("server.key".to_string()), }; assert_eq!(cert.cert_path(), Some("./certs/server.crt".to_string())); assert_eq!(cert.key_path(), Some("./certs/server.key".to_string())); } #[test] fn certificate_validate_creates_directory_when_missing() { let cert_dir = make_temp_dir("nxmesh-master-cert-create").join("nested"); let san = Ia5String::try_from("localhost".to_string()); assert!(san.is_ok()); let san = san.unwrap_or_else(|_| unreachable!()); let cert = CertificateSettings { cert_dir: cert_dir.to_string_lossy().to_string(), san_dns: vec![san], san_ip: Vec::new(), cert_path: None, key_path: None, }; let result = cert.validate(); assert!(result.is_ok()); assert!(cert_dir.exists()); let _ = fs::remove_dir_all(cert_dir.parent().unwrap_or(&cert_dir)); } #[test] fn certificate_validate_fails_when_only_cert_path_is_set() { let cert_dir = make_temp_dir("nxmesh-master-cert-partial"); let san = Ia5String::try_from("localhost".to_string()); assert!(san.is_ok()); let san = san.unwrap_or_else(|_| unreachable!()); let cert = CertificateSettings { cert_dir: cert_dir.to_string_lossy().to_string(), san_dns: vec![san], san_ip: Vec::new(), cert_path: Some("server.crt".to_string()), key_path: None, }; let result = cert.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_default(); assert!(msg.contains("Both certificate and key paths must be provided")); let _ = fs::remove_dir_all(&cert_dir); } #[test] fn certificate_validate_fails_with_unspecified_ip() { let cert_dir = make_temp_dir("nxmesh-master-cert-unspecified-ip"); let cert = CertificateSettings { cert_dir: cert_dir.to_string_lossy().to_string(), san_dns: Vec::new(), san_ip: vec![IpAddr::V4(Ipv4Addr::UNSPECIFIED)], cert_path: None, key_path: None, }; let result = cert.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_default(); assert!(msg.contains("SAN IP entries cannot be unspecified")); let _ = fs::remove_dir_all(&cert_dir); } #[test] fn certificate_validate_fails_without_any_san_entries() { let cert_dir = make_temp_dir("nxmesh-master-cert-no-san"); let cert = CertificateSettings { cert_dir: cert_dir.to_string_lossy().to_string(), san_dns: Vec::new(), san_ip: Vec::new(), cert_path: None, key_path: None, }; let result = cert.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_default(); assert!(msg.contains("At least one SAN entry")); let _ = fs::remove_dir_all(&cert_dir); } #[test] fn ia5string_vec_round_trip_serialization() { #[derive(Serialize, Deserialize)] struct Wrapper { #[serde( deserialize_with = "deserialize_ia5string_vec", serialize_with = "serialize_ia5string_vec" )] san_dns: Vec, } let first = Ia5String::try_from("localhost".to_string()); assert!(first.is_ok()); let second = Ia5String::try_from("example.com".to_string()); assert!(second.is_ok()); let first = first.unwrap_or_else(|_| unreachable!()); let second = second.unwrap_or_else(|_| unreachable!()); let data = Wrapper { san_dns: vec![first, second], }; let encoded = serde_json::to_string(&data); assert!(encoded.is_ok()); let encoded = encoded.unwrap_or_default(); assert!(encoded.contains("localhost")); assert!(encoded.contains("example.com")); let decoded: Result = serde_json::from_str(&encoded); assert!(decoded.is_ok()); let decoded = decoded.unwrap_or(Wrapper { san_dns: Vec::new(), }); assert_eq!(decoded.san_dns.len(), 2); } }