use config::{Config, ConfigError, Environment, File}; use serde::{Deserialize, Deserializer, Serialize}; use std::{os::unix::fs::PermissionsExt, str::FromStr}; use tracing::level_filters::LevelFilter; const NGINX_BINARY_PATH_TEMPLATE: &str = "{{nginx_binary_path}}"; const NGINX_DEFAULT_BINARY: &str = "nginx"; type ValidationError = String; trait Validate { fn validate(&self) -> Result<(), ValidationError>; } /// Agent settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub grpc: GrpcSettings, #[serde(default)] pub log: LogSettings, pub nginx: Option, } /// gRPC client settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GrpcSettings { pub connection_string: String, pub m_auth: MAuthSettings, #[serde(default)] pub cors: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum MAuthSettings { Tls(TLSSettings), } /// TLS certificate settings #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TLSSettings { RawPath { ca_path: String, cert_path: String, key_path: String, }, ZipPath { cert_zip_path: String, }, } /// 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(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NginxSettings { #[serde(default = "default_nginx_config_path")] pub nginx_config_path: String, // #[serde(default = "default_nginx_binary_path")] #[serde(default)] pub nginx_binary_path: Option, // commands #[serde(default = "default_nginx_reload_command")] pub override_nginx_reload_command: Vec, #[serde(default = "default_nginx_test_command")] pub override_nginx_test_command: Vec, // timeouts #[serde(default = "default_nginx_reload_timeout_seconds")] pub nginx_reload_timeout_seconds: u64, #[serde(default = "default_nginx_test_timeout_seconds")] pub nginx_test_timeout_seconds: u64, } impl Validate for Settings { fn validate(&self) -> Result<(), ValidationError> { self.grpc.validate()?; if let Some(nginx) = &self.nginx { nginx.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/agent/default").required(false)) .add_source(File::with_name(&format!("config/agent/{}", run_mode)).required(false)) .add_source(Environment::with_prefix("NXMESH").separator("__")) .build()?; let mut settings: Self = settings.try_deserialize()?; settings.validate().map_err(ConfigError::Message)?; if let Some(nginx) = &mut settings.nginx { nginx.validate().map_err(ConfigError::Message)?; // replace binary path template in commands with actual binary path, if the template is present nginx .override_nginx_reload_command .iter_mut() .for_each(|cmd| { *cmd = cmd.replace( NGINX_BINARY_PATH_TEMPLATE, &nginx .nginx_binary_path .clone() .unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()), ); }); nginx .override_nginx_test_command .iter_mut() .for_each(|cmd| { *cmd = cmd.replace( NGINX_BINARY_PATH_TEMPLATE, &nginx .nginx_binary_path .clone() .unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()), ); }); } Ok(settings) } } impl Validate for GrpcSettings { fn validate(&self) -> Result<(), ValidationError> { if self.connection_string.is_empty() { return Err("gRPC connection string cannot be empty".into()); } self.m_auth.validate()?; if let Some(cors) = &self.cors { cors.validate()?; } Ok(()) } } impl Validate for MAuthSettings { fn validate(&self) -> Result<(), ValidationError> { match self { MAuthSettings::Tls(tls_settings) => tls_settings.validate()?, } Ok(()) } } impl Validate for TLSSettings { fn validate(&self) -> Result<(), ValidationError> { match self { TLSSettings::RawPath { ca_path, cert_path, key_path, } => { if !std::path::Path::new(ca_path).exists() { return Err(format!("CA file not found: {}", ca_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)); } } TLSSettings::ZipPath { cert_zip_path } => { if !std::path::Path::new(cert_zip_path).exists() { return Err(format!("Certificate zip file not found: {}", cert_zip_path)); } } } Ok(()) } } impl Validate for CorsSettings { fn validate(&self) -> Result<(), ValidationError> { Ok(()) } } impl Validate for NginxSettings { fn validate(&self) -> Result<(), ValidationError> { match &self.nginx_binary_path { Some(path) if path.is_empty() => { return Err("Nginx binary path cannot be empty".into()); } Some(path) if !std::path::Path::new(path).exists() => { return Err(format!("Nginx binary not found: {}", path)); } Some(path) if !std::fs::metadata(path) .map_err(|e| format!("Failed to read nginx binary metadata: {}", e))? .permissions() .mode() & 0o111 != 0 => { return Err(format!("Nginx binary is not executable: {}", path)); } _ => {} } if self.nginx_config_path.is_empty() { return Err("Nginx config path cannot be empty".into()); } if !std::path::Path::new(&self.nginx_config_path).exists() { return Err(format!( "Nginx config file not found: {}", self.nginx_config_path )); } // ensure reload and test commands contain the binary path template if !&self .override_nginx_reload_command .join(" ") .contains(NGINX_BINARY_PATH_TEMPLATE) { return Err(format!( "Nginx reload command must contain the binary path template '{}': {}", NGINX_BINARY_PATH_TEMPLATE, self.override_nginx_reload_command.join(" ") )); } if !&self .override_nginx_test_command .join(" ") .contains(NGINX_BINARY_PATH_TEMPLATE) { return Err(format!( "Nginx test command must contain the binary path template '{}': {}", NGINX_BINARY_PATH_TEMPLATE, self.override_nginx_test_command.join(" ") )); } Ok(()) } } fn default_log_level() -> LevelFilter { LevelFilter::INFO } fn default_nginx_config_path() -> String { "/etc/nginx/nginx.conf".into() } fn default_nginx_reload_command() -> Vec { vec![ NGINX_BINARY_PATH_TEMPLATE.to_string(), "-s".to_string(), "reload".to_string(), ] } fn default_nginx_test_command() -> Vec { vec![NGINX_BINARY_PATH_TEMPLATE.to_string(), "-t".to_string()] } fn default_nginx_reload_timeout_seconds() -> u64 { 30 } fn default_nginx_test_timeout_seconds() -> u64 { 30 } 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 std::{ fs, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, }; use tempfile::TempDir; 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::(); } fn write_file(path: &Path) { let result = fs::write(path, b"content"); assert!(result.is_ok()); } fn create_exec_file(path: &Path) { write_file(path); let metadata = fs::metadata(path); assert!(metadata.is_ok()); let metadata = metadata.ok(); assert!(metadata.is_some()); let metadata = metadata.unwrap_or_else(|| unreachable!()); let mut perms = metadata.permissions(); perms.set_mode(0o755); let result = fs::set_permissions(path, perms); assert!(result.is_ok()); } fn create_non_exec_file(path: &Path) { write_file(path); let metadata = fs::metadata(path); assert!(metadata.is_ok()); let metadata = metadata.ok(); assert!(metadata.is_some()); let metadata = metadata.unwrap_or_else(|| unreachable!()); let mut perms = metadata.permissions(); perms.set_mode(0o644); let result = fs::set_permissions(path, perms); assert!(result.is_ok()); } fn valid_tls_raw_paths(temp_dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) { let ca_path = temp_dir.path().join("ca.pem"); let cert_path = temp_dir.path().join("cert.pem"); let key_path = temp_dir.path().join("key.pem"); write_file(&ca_path); write_file(&cert_path); write_file(&key_path); (ca_path, cert_path, key_path) } #[test] fn tls_raw_path_validate_succeeds_when_all_files_exist() { let temp_dir = TempDir::new(); assert!(temp_dir.is_ok()); let temp_dir = temp_dir.ok(); assert!(temp_dir.is_some()); let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); let (ca_path, cert_path, key_path) = valid_tls_raw_paths(&temp_dir); let settings = TLSSettings::RawPath { ca_path: ca_path.to_string_lossy().to_string(), cert_path: cert_path.to_string_lossy().to_string(), key_path: key_path.to_string_lossy().to_string(), }; assert!(settings.validate().is_ok()); } #[test] fn tls_raw_path_validate_fails_when_ca_missing() { let settings = TLSSettings::RawPath { ca_path: "/tmp/does-not-exist-ca.pem".into(), cert_path: "/tmp/does-not-exist-cert.pem".into(), key_path: "/tmp/does-not-exist-key.pem".into(), }; let result = settings.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_else(|| unreachable!()); assert!(msg.contains("CA file not found")); } #[test] fn tls_zip_path_validate_fails_when_zip_missing() { let settings = TLSSettings::ZipPath { cert_zip_path: "/tmp/missing-certs.zip".into(), }; let result = settings.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_else(|| unreachable!()); assert!(msg.contains("Certificate zip file not found")); } #[test] fn grpc_validate_fails_when_connection_string_empty() { let settings = GrpcSettings { connection_string: "".into(), m_auth: MAuthSettings::Tls(TLSSettings::ZipPath { cert_zip_path: "/tmp/does-not-exist.zip".into(), }), cors: None, }; let result = settings.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_else(|| unreachable!()); assert!(msg.contains("gRPC connection string cannot be empty")); } #[test] fn nginx_validate_succeeds_for_valid_paths_and_commands() { let temp_dir = TempDir::new(); assert!(temp_dir.is_ok()); let temp_dir = temp_dir.ok(); assert!(temp_dir.is_some()); let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); let nginx_binary = temp_dir.path().join("nginx"); let nginx_config = temp_dir.path().join("nginx.conf"); create_exec_file(&nginx_binary); write_file(&nginx_config); let nginx = NginxSettings { nginx_config_path: nginx_config.to_string_lossy().to_string(), nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), override_nginx_reload_command: default_nginx_reload_command(), override_nginx_test_command: default_nginx_test_command(), nginx_reload_timeout_seconds: 30, nginx_test_timeout_seconds: 30, }; assert!(nginx.validate().is_ok()); } #[test] fn nginx_validate_fails_for_non_executable_binary() { let temp_dir = TempDir::new(); assert!(temp_dir.is_ok()); let temp_dir = temp_dir.ok(); assert!(temp_dir.is_some()); let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); let nginx_binary = temp_dir.path().join("nginx"); let nginx_config = temp_dir.path().join("nginx.conf"); create_non_exec_file(&nginx_binary); write_file(&nginx_config); let nginx = NginxSettings { nginx_config_path: nginx_config.to_string_lossy().to_string(), nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), override_nginx_reload_command: default_nginx_reload_command(), override_nginx_test_command: default_nginx_test_command(), nginx_reload_timeout_seconds: 30, nginx_test_timeout_seconds: 30, }; let result = nginx.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_else(|| unreachable!()); assert!(msg.contains("Nginx binary is not executable")); } #[test] fn nginx_validate_fails_when_reload_command_lacks_template() { let temp_dir = TempDir::new(); assert!(temp_dir.is_ok()); let temp_dir = temp_dir.ok(); assert!(temp_dir.is_some()); let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); let nginx_binary = temp_dir.path().join("nginx"); let nginx_config = temp_dir.path().join("nginx.conf"); create_exec_file(&nginx_binary); write_file(&nginx_config); let nginx = NginxSettings { nginx_config_path: nginx_config.to_string_lossy().to_string(), nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), override_nginx_reload_command: vec!["nginx".into(), "-s".into(), "reload".into()], override_nginx_test_command: default_nginx_test_command(), nginx_reload_timeout_seconds: 30, nginx_test_timeout_seconds: 30, }; let result = nginx.validate(); assert!(result.is_err()); let msg = result.err().unwrap_or_else(|| unreachable!()); assert!(msg.contains("Nginx reload command must contain the binary path template")); } #[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 original = Wrapper { level: LevelFilter::DEBUG, }; let encoded = serde_json::to_string(&original); assert!(encoded.is_ok()); let encoded = encoded.ok(); assert!(encoded.is_some()); let encoded = encoded.unwrap_or_else(|| unreachable!()); assert!(encoded.to_lowercase().contains("debug")); let decoded = serde_json::from_str::(&encoded); assert!(decoded.is_ok()); let decoded = decoded.ok(); assert!(decoded.is_some()); let decoded = decoded.unwrap_or_else(|| unreachable!()); assert_eq!(decoded.level, LevelFilter::DEBUG); } }