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 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::(); } }