From c52c0b3103a0861946ebe5b6711f75199fd9403d Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 31 May 2026 02:56:03 +0000 Subject: [PATCH] refactor: reorganize settings into separate modules for improved structure and maintainability --- apps/nxmesh-master/src/config/settings.rs | 586 ------------------ .../nxmesh-master/src/config/settings/auth.rs | 58 ++ .../nxmesh-master/src/config/settings/cert.rs | 276 +++++++++ .../nxmesh-master/src/config/settings/cors.rs | 34 + .../src/config/settings/database.rs | 48 ++ .../nxmesh-master/src/config/settings/grpc.rs | 53 ++ apps/nxmesh-master/src/config/settings/log.rs | 81 +++ apps/nxmesh-master/src/config/settings/mod.rs | 75 +++ .../src/config/settings/server.rs | 103 +++ 9 files changed, 728 insertions(+), 586 deletions(-) delete mode 100644 apps/nxmesh-master/src/config/settings.rs create mode 100644 apps/nxmesh-master/src/config/settings/auth.rs create mode 100644 apps/nxmesh-master/src/config/settings/cert.rs create mode 100644 apps/nxmesh-master/src/config/settings/cors.rs create mode 100644 apps/nxmesh-master/src/config/settings/database.rs create mode 100644 apps/nxmesh-master/src/config/settings/grpc.rs create mode 100644 apps/nxmesh-master/src/config/settings/log.rs create mode 100644 apps/nxmesh-master/src/config/settings/mod.rs create mode 100644 apps/nxmesh-master/src/config/settings/server.rs diff --git a/apps/nxmesh-master/src/config/settings.rs b/apps/nxmesh-master/src/config/settings.rs deleted file mode 100644 index 64f01e2..0000000 --- a/apps/nxmesh-master/src/config/settings.rs +++ /dev/null @@ -1,586 +0,0 @@ -use config::{Config, ConfigError, Environment, File}; -use rcgen::string::Ia5String; -use serde::{Deserialize, Deserializer, Serialize}; -use std::{net::IpAddr, str::FromStr}; -use tracing::level_filters::LevelFilter; - -type ValidationError = String; - -trait Validate { - fn validate(&self) -> Result<(), ValidationError>; -} - -/// Master server settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Settings { - pub server: ServerSettings, - pub database: DatabaseSettings, - pub grpc: GrpcSettings, - pub auth: AuthSettings, - #[serde(default)] - pub log: LogSettings, -} - -/// HTTP server settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerSettings { - #[serde(default = "default_server_bind_address")] - pub bind_address: String, - #[serde(default = "default_server_port")] - pub port: u16, - #[serde(default)] - pub certificate: CertificateSettings, - #[serde(default)] - pub cors: Option, -} - -/// Database connection settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DatabaseSettings { - pub url: String, - pub max_connections: Option, -} - -/// gRPC server settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GrpcSettings { - #[serde(default = "default_grpc_bind_address")] - pub bind_address: String, - #[serde(default = "default_grpc_port")] - pub port: u16, - #[serde(default)] - pub certificate: CertificateSettings, - #[serde(default)] - pub cors: Option, -} - -/// Authentication settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthSettings { - pub jwt_secret: String, - #[serde(default = "default_jwt_expiration_hours")] - pub jwt_expiration_hours: u64, -} - -/// 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)] - cert_path: Option, - #[serde(default)] - 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)) - } -} - -/// CORS settings -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CorsSettings { - #[serde(default)] - pub allowed_origins: Vec, - #[serde(default)] - pub allowed_methods: Vec, - #[serde(default)] - pub allowed_headers: Vec, - #[serde(default)] - pub allow_credentials: bool, -} - -/// Logging settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LogSettings { - #[serde( - deserialize_with = "deserialize_level_filter", - serialize_with = "serialize_level_filter" - )] - pub level: LevelFilter, -} - -impl Default for LogSettings { - fn default() -> Self { - Self { - level: default_log_level(), - } - } -} - -impl Validate for Settings { - fn validate(&self) -> Result<(), ValidationError> { - self.server.validate()?; - self.grpc.validate()?; - self.database.validate()?; - self.auth.validate()?; - Ok(()) - } -} - -impl Settings { - /// Load settings from config files and environment - pub fn load() -> Result { - let run_mode = std::env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); - - let settings = Config::builder() - .add_source(File::with_name("config/default").required(false)) - .add_source(File::with_name(&format!("config/{}", run_mode)).required(false)) - .add_source(File::with_name("config/master/default").required(false)) - .add_source(File::with_name(&format!("config/master/{}", run_mode)).required(false)) - .add_source(Environment::with_prefix("NXMESH").separator("__")) - .build()?; - - let settings: Self = settings.try_deserialize()?; - - settings.validate().map_err(ConfigError::Message)?; - - Ok(settings) - } -} - -impl Validate for ServerSettings { - fn validate(&self) -> Result<(), ValidationError> { - if self.bind_address.is_empty() { - return Err("Server bind address cannot be empty".into()); - } - if self.port == 0 { - return Err("Server port must be greater than 0".into()); - } - self.certificate.validate()?; - if let Some(cors) = &self.cors { - cors.validate()?; - } - Ok(()) - } -} - -impl Validate for GrpcSettings { - fn validate(&self) -> Result<(), ValidationError> { - if self.bind_address.is_empty() { - return Err("gRPC bind address cannot be empty".into()); - } - if self.port == 0 { - return Err("gRPC port must be greater than 0".into()); - } - self.certificate.validate()?; - if let Some(cors) = &self.cors { - cors.validate()?; - } - Ok(()) - } -} - -impl Validate for DatabaseSettings { - fn validate(&self) -> Result<(), ValidationError> { - if self.url.is_empty() { - return Err("Database URL cannot be empty".into()); - } - if let Some(max_connections) = self.max_connections - && max_connections == 0 - { - return Err("Max database connections must be greater than 0".into()); - } - Ok(()) - } -} - -impl Validate for AuthSettings { - fn validate(&self) -> Result<(), ValidationError> { - if self.jwt_secret.is_empty() { - return Err("JWT secret cannot be empty".into()); - } - if self.jwt_expiration_hours == 0 { - return Err("JWT expiration hours must be greater than 0".into()); - } - Ok(()) - } -} - -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(()) - } -} - -impl Validate for CorsSettings { - fn validate(&self) -> Result<(), ValidationError> { - Ok(()) - } -} - -fn default_jwt_expiration_hours() -> u64 { - 24 -} - -fn default_server_bind_address() -> String { - "0.0.0.0".into() -} - -fn default_server_port() -> u16 { - 8080 -} - -fn default_grpc_bind_address() -> String { - "0.0.0.0".into() -} - -fn default_grpc_port() -> u16 { - 50051 -} - -fn default_log_level() -> LevelFilter { - LevelFilter::INFO -} - -fn default_cert_folder() -> String { - "./certs".into() -} - -fn deserialize_level_filter<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let s = String::deserialize(deserializer)?; - LevelFilter::from_str(&s).map_err(serde::de::Error::custom) -} - -fn serialize_level_filter(level: &LevelFilter, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_str(&level.to_string()) -} - -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::(); - assert_send_sync::(); - assert_send_sync::(); - assert_send_sync::(); - assert_send_sync::(); - assert_send_sync::(); - 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 database_validate_fails_for_zero_max_connections() { - let db = DatabaseSettings { - url: "postgres://localhost/db".to_string(), - max_connections: Some(0), - }; - - let result = db.validate(); - assert!(result.is_err()); - let msg = result.err().unwrap_or_default(); - assert!(msg.contains("Max database connections must be greater than 0")); - } - - #[test] - fn auth_validate_fails_for_empty_secret() { - let auth = AuthSettings { - jwt_secret: "".to_string(), - jwt_expiration_hours: 24, - }; - - let result = auth.validate(); - assert!(result.is_err()); - let msg = result.err().unwrap_or_default(); - assert!(msg.contains("JWT secret cannot be empty")); - } - - #[test] - fn server_validate_fails_for_zero_port() { - let cert_dir = make_temp_dir("nxmesh-master-server-validate"); - let san = Ia5String::try_from("localhost".to_string()); - assert!(san.is_ok()); - let san = san.unwrap_or_else(|_| unreachable!()); - let server = ServerSettings { - bind_address: "0.0.0.0".to_string(), - port: 0, - certificate: CertificateSettings { - cert_dir: cert_dir.to_string_lossy().to_string(), - san_dns: vec![san], - san_ip: Vec::new(), - cert_path: None, - key_path: None, - }, - cors: None, - }; - - let result = server.validate(); - assert!(result.is_err()); - let msg = result.err().unwrap_or_default(); - assert!(msg.contains("Server port must be greater than 0")); - - let _ = fs::remove_dir_all(&cert_dir); - } - - #[test] - fn level_filter_round_trip_serialization() { - #[derive(Serialize, Deserialize)] - struct Wrapper { - #[serde( - deserialize_with = "deserialize_level_filter", - serialize_with = "serialize_level_filter" - )] - level: LevelFilter, - } - - let data = Wrapper { - level: LevelFilter::DEBUG, - }; - - let encoded = serde_json::to_string(&data); - assert!(encoded.is_ok()); - let encoded = encoded.unwrap_or_default(); - assert!(encoded.to_lowercase().contains("debug")); - - let decoded: Result = serde_json::from_str(&encoded); - assert!(decoded.is_ok()); - let decoded = decoded.unwrap_or(Wrapper { - level: LevelFilter::ERROR, - }); - assert_eq!(decoded.level, LevelFilter::DEBUG); - } - - #[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); - } -} diff --git a/apps/nxmesh-master/src/config/settings/auth.rs b/apps/nxmesh-master/src/config/settings/auth.rs new file mode 100644 index 0000000..76daa61 --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/auth.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::settings::{Validate, ValidationError}; + +/// Authentication settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthSettings { + pub jwt_secret: String, + #[serde(default = "default_jwt_expiration_hours")] + pub jwt_expiration_hours: u64, +} + +impl Validate for AuthSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.jwt_secret.is_empty() { + return Err("JWT secret cannot be empty".into()); + } + if self.jwt_expiration_hours == 0 { + return Err("JWT expiration hours must be greater than 0".into()); + } + Ok(()) + } +} + +fn default_jwt_expiration_hours() -> u64 { + 24 +} + +#[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::(); + } + + #[test] + fn auth_validate_fails_for_empty_secret() { + let auth = AuthSettings { + jwt_secret: "".to_string(), + jwt_expiration_hours: 24, + }; + + let result = auth.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("JWT secret cannot be empty")); + } +} diff --git a/apps/nxmesh-master/src/config/settings/cert.rs b/apps/nxmesh-master/src/config/settings/cert.rs new file mode 100644 index 0000000..2f69460 --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/cert.rs @@ -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, + #[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); + } +} diff --git a/apps/nxmesh-master/src/config/settings/cors.rs b/apps/nxmesh-master/src/config/settings/cors.rs new file mode 100644 index 0000000..262588a --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/cors.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::settings::{Validate, ValidationError}; + +/// CORS settings +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CorsSettings { + #[serde(default)] + pub allowed_origins: Vec, + #[serde(default)] + pub allowed_methods: Vec, + #[serde(default)] + pub allowed_headers: Vec, + #[serde(default)] + pub allow_credentials: bool, +} + +impl Validate for CorsSettings { + fn validate(&self) -> Result<(), ValidationError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/apps/nxmesh-master/src/config/settings/database.rs b/apps/nxmesh-master/src/config/settings/database.rs new file mode 100644 index 0000000..982a454 --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/database.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::settings::{Validate, ValidationError}; + +/// Database connection settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseSettings { + pub url: String, + pub max_connections: Option, +} + +impl Validate for DatabaseSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.url.is_empty() { + return Err("Database URL cannot be empty".into()); + } + if let Some(max_connections) = self.max_connections + && max_connections == 0 + { + return Err("Max database connections must be greater than 0".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + #[test] + fn database_validate_fails_for_zero_max_connections() { + let db = DatabaseSettings { + url: "postgres://localhost/db".to_string(), + max_connections: Some(0), + }; + + let result = db.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("Max database connections must be greater than 0")); + } +} diff --git a/apps/nxmesh-master/src/config/settings/grpc.rs b/apps/nxmesh-master/src/config/settings/grpc.rs new file mode 100644 index 0000000..837c2ad --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/grpc.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::settings::{ + Validate, ValidationError, cert::CertificateSettings, cors::CorsSettings, +}; + +/// gRPC server settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrpcSettings { + #[serde(default = "default_grpc_bind_address")] + pub bind_address: String, + #[serde(default = "default_grpc_port")] + pub port: u16, + #[serde(default)] + pub certificate: CertificateSettings, + #[serde(default)] + pub cors: Option, +} + +impl Validate for GrpcSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.bind_address.is_empty() { + return Err("gRPC bind address cannot be empty".into()); + } + if self.port == 0 { + return Err("gRPC port must be greater than 0".into()); + } + self.certificate.validate()?; + if let Some(cors) = &self.cors { + cors.validate()?; + } + Ok(()) + } +} + +fn default_grpc_bind_address() -> String { + "0.0.0.0".into() +} + +fn default_grpc_port() -> u16 { + 50051 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/apps/nxmesh-master/src/config/settings/log.rs b/apps/nxmesh-master/src/config/settings/log.rs new file mode 100644 index 0000000..b4c6d24 --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/log.rs @@ -0,0 +1,81 @@ +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize}; +use tracing::level_filters::LevelFilter; + +/// Logging settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogSettings { + #[serde( + deserialize_with = "deserialize_level_filter", + serialize_with = "serialize_level_filter" + )] + pub level: LevelFilter, +} + +impl Default for LogSettings { + fn default() -> Self { + Self { + level: default_log_level(), + } + } +} + +fn default_log_level() -> LevelFilter { + LevelFilter::INFO +} + +fn deserialize_level_filter<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + LevelFilter::from_str(&s).map_err(serde::de::Error::custom) +} + +fn serialize_level_filter(level: &LevelFilter, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&level.to_string()) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[test] + fn level_filter_round_trip_serialization() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde( + deserialize_with = "deserialize_level_filter", + serialize_with = "serialize_level_filter" + )] + level: LevelFilter, + } + + let data = Wrapper { + level: LevelFilter::DEBUG, + }; + + let encoded = serde_json::to_string(&data); + assert!(encoded.is_ok()); + let encoded = encoded.unwrap_or_default(); + assert!(encoded.to_lowercase().contains("debug")); + + let decoded: Result = serde_json::from_str(&encoded); + assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or(Wrapper { + level: LevelFilter::ERROR, + }); + assert_eq!(decoded.level, LevelFilter::DEBUG); + } +} diff --git a/apps/nxmesh-master/src/config/settings/mod.rs b/apps/nxmesh-master/src/config/settings/mod.rs new file mode 100644 index 0000000..47fa30a --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/mod.rs @@ -0,0 +1,75 @@ +use config::{Config, ConfigError, Environment, File}; +use serde::{Deserialize, Serialize}; + +pub type ValidationError = String; + +pub mod auth; +pub mod cert; +pub mod cors; +pub mod database; +pub mod grpc; +pub mod log; +pub mod server; + +use auth::AuthSettings; +use database::DatabaseSettings; +use grpc::GrpcSettings; +use log::LogSettings; +use server::ServerSettings; + +pub trait Validate { + fn validate(&self) -> Result<(), ValidationError>; +} + +/// Master server settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub server: ServerSettings, + pub database: DatabaseSettings, + pub grpc: GrpcSettings, + pub auth: AuthSettings, + #[serde(default)] + pub log: LogSettings, +} + +impl Validate for Settings { + fn validate(&self) -> Result<(), ValidationError> { + self.server.validate()?; + self.grpc.validate()?; + self.database.validate()?; + self.auth.validate()?; + Ok(()) + } +} + +impl Settings { + /// Load settings from config files and environment + pub fn load() -> Result { + let run_mode = std::env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); + + let settings = Config::builder() + .add_source(File::with_name("config/default").required(false)) + .add_source(File::with_name(&format!("config/{}", run_mode)).required(false)) + .add_source(File::with_name("config/master/default").required(false)) + .add_source(File::with_name(&format!("config/master/{}", run_mode)).required(false)) + .add_source(Environment::with_prefix("NXMESH").separator("__")) + .build()?; + + let settings: Self = settings.try_deserialize()?; + + settings.validate().map_err(ConfigError::Message)?; + + Ok(settings) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/apps/nxmesh-master/src/config/settings/server.rs b/apps/nxmesh-master/src/config/settings/server.rs new file mode 100644 index 0000000..e088804 --- /dev/null +++ b/apps/nxmesh-master/src/config/settings/server.rs @@ -0,0 +1,103 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::settings::{ + Validate, ValidationError, cert::CertificateSettings, cors::CorsSettings, +}; + +/// HTTP server settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerSettings { + #[serde(default = "default_server_bind_address")] + pub bind_address: String, + #[serde(default = "default_server_port")] + pub port: u16, + #[serde(default)] + pub certificate: CertificateSettings, + #[serde(default)] + pub cors: Option, +} + +impl Validate for ServerSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.bind_address.is_empty() { + return Err("Server bind address cannot be empty".into()); + } + if self.port == 0 { + return Err("Server port must be greater than 0".into()); + } + self.certificate.validate()?; + if let Some(cors) = &self.cors { + cors.validate()?; + } + Ok(()) + } +} + +fn default_server_bind_address() -> String { + "0.0.0.0".into() +} + +fn default_server_port() -> u16 { + 8080 +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use rcgen::string::Ia5String; + + 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 server_validate_fails_for_zero_port() { + let cert_dir = make_temp_dir("nxmesh-master-server-validate"); + let san = Ia5String::try_from("localhost".to_string()); + assert!(san.is_ok()); + let san = san.unwrap_or_else(|_| unreachable!()); + let server = ServerSettings { + bind_address: "0.0.0.0".to_string(), + port: 0, + certificate: CertificateSettings { + cert_dir: cert_dir.to_string_lossy().to_string(), + san_dns: vec![san], + san_ip: Vec::new(), + cert_path: None, + key_path: None, + }, + cors: None, + }; + + let result = server.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("Server port must be greater than 0")); + + let _ = fs::remove_dir_all(&cert_dir); + } +}