refactor: reorganize settings into separate modules for improved structure and maintainability
This commit is contained in:
276
apps/nxmesh-master/src/config/settings/cert.rs
Normal file
276
apps/nxmesh-master/src/config/settings/cert.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
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<Ia5String>,
|
||||
#[serde(default)]
|
||||
pub san_ip: Vec<IpAddr>,
|
||||
#[serde(default)]
|
||||
pub cert_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub key_path: Option<String>,
|
||||
}
|
||||
|
||||
impl CertificateSettings {
|
||||
pub fn cert_path(&self) -> Option<String> {
|
||||
self.cert_path
|
||||
.as_ref()
|
||||
.map(|p| format!("{}/{}", self.cert_dir, p))
|
||||
}
|
||||
|
||||
pub fn key_path(&self) -> Option<String> {
|
||||
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<Vec<Ia5String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let vec = Vec::<String>::deserialize(deserializer)?;
|
||||
vec.into_iter()
|
||||
.map(|s| Ia5String::try_from(s).map_err(serde::de::Error::custom))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn serialize_ia5string_vec<S>(vec: &Vec<Ia5String>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let string_vec: Vec<String> = 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<T: Send + Sync>() {}
|
||||
assert_send_sync::<CertificateSettings>();
|
||||
}
|
||||
|
||||
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<Ia5String>,
|
||||
}
|
||||
|
||||
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<Wrapper, _> = 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user