diff --git a/apps/nxmesh-agent/src/config/settings.rs b/apps/nxmesh-agent/src/config/settings.rs deleted file mode 100644 index 11c419a..0000000 --- a/apps/nxmesh-agent/src/config/settings.rs +++ /dev/null @@ -1,560 +0,0 @@ -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); - } -} diff --git a/apps/nxmesh-agent/src/config/settings/auth.rs b/apps/nxmesh-agent/src/config/settings/auth.rs new file mode 100644 index 0000000..fe9206c --- /dev/null +++ b/apps/nxmesh-agent/src/config/settings/auth.rs @@ -0,0 +1,166 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::settings::{Validate, ValidationError}; + +#[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, + }, +} + +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(()) + } +} + +#[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::(); + } + + 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")); + } +} diff --git a/apps/nxmesh-agent/src/config/settings/cors.rs b/apps/nxmesh-agent/src/config/settings/cors.rs new file mode 100644 index 0000000..262588a --- /dev/null +++ b/apps/nxmesh-agent/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-agent/src/config/settings/grpc.rs b/apps/nxmesh-agent/src/config/settings/grpc.rs new file mode 100644 index 0000000..d169610 --- /dev/null +++ b/apps/nxmesh-agent/src/config/settings/grpc.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +use super::super::settings::{Validate, ValidationError}; +use super::{auth::MAuthSettings, cors::CorsSettings}; + +/// gRPC client settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrpcSettings { + pub connection_string: String, + pub m_auth: MAuthSettings, + #[serde(default)] + pub cors: Option, +} + +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(()) + } +} + +#[cfg(test)] +mod tests { + + use crate::config::settings::TLSSettings; + + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[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")); + } +} diff --git a/apps/nxmesh-agent/src/config/settings/log.rs b/apps/nxmesh-agent/src/config/settings/log.rs new file mode 100644 index 0000000..a251a32 --- /dev/null +++ b/apps/nxmesh-agent/src/config/settings/log.rs @@ -0,0 +1,82 @@ +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 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); + } +} diff --git a/apps/nxmesh-agent/src/config/settings/mod.rs b/apps/nxmesh-agent/src/config/settings/mod.rs new file mode 100644 index 0000000..f55cd79 --- /dev/null +++ b/apps/nxmesh-agent/src/config/settings/mod.rs @@ -0,0 +1,77 @@ +use config::{Config, ConfigError, Environment, File}; +use serde::{Deserialize, Serialize}; + +mod auth; +mod cors; +mod grpc; +mod log; +mod nginx; + +pub use auth::*; +pub use cors::*; +pub use grpc::*; +pub use log::*; +pub use nginx::*; + +pub type ValidationError = String; + +pub 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, +} + +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)?; + nginx.transform_commands(); + } + + Ok(settings) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_ensure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/apps/nxmesh-agent/src/config/settings/nginx.rs b/apps/nxmesh-agent/src/config/settings/nginx.rs new file mode 100644 index 0000000..b5c5142 --- /dev/null +++ b/apps/nxmesh-agent/src/config/settings/nginx.rs @@ -0,0 +1,280 @@ +use std::os::unix::fs::PermissionsExt; + +use serde::{Deserialize, Serialize}; + +use crate::config::settings::{Validate, ValidationError}; + +const NGINX_BINARY_PATH_TEMPLATE: &str = "{{nginx_binary_path}}"; +const NGINX_DEFAULT_BINARY: &str = "nginx"; + +#[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 NginxSettings { + /// Transforms the reload and test commands by replacing the binary path template with the actual binary path if provided. + /// This MUST be called after validation to ensure the binary path is valid and the commands contain the template. + pub fn transform_commands(&mut self) { + self.override_nginx_reload_command = self.transformed_reload_command(); + self.override_nginx_test_command = self.transformed_test_command(); + } + + fn transformed_reload_command(&self) -> Vec { + self.override_nginx_reload_command + .iter() + .map(|cmd| { + cmd.replace( + NGINX_BINARY_PATH_TEMPLATE, + &self + .nginx_binary_path + .clone() + .unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()), + ) + }) + .collect() + } + + fn transformed_test_command(&self) -> Vec { + self.override_nginx_test_command + .iter() + .map(|cmd| { + cmd.replace( + NGINX_BINARY_PATH_TEMPLATE, + &self + .nginx_binary_path + .clone() + .unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()), + ) + }) + .collect() + } +} + +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_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 +} + +#[cfg(test)] +mod tests { + use std::{fs, os::unix::fs::PermissionsExt, path::Path}; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn 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()); + } + + #[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")); + } +}