Merge branch 'master' into feature/nginx-handler
This commit is contained in:
119
apps/nxmesh-agent/src/cli/import_certs.rs
Normal file
119
apps/nxmesh-agent/src/cli/import_certs.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Import certificates for agent from zip file or separate cert and key files")]
|
||||
pub struct ImportCertsCommand {
|
||||
/// Zip file containing ca.pem cert.pem and key.pem
|
||||
#[arg(value_name = "ZIP_FILE", group = "input_source")]
|
||||
zip: Option<String>,
|
||||
/// Certificate name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "cert.pem",
|
||||
value_name = "CERT_NAME"
|
||||
)]
|
||||
cert_name: Option<String>,
|
||||
/// Key name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "key.pem",
|
||||
value_name = "KEY_NAME"
|
||||
)]
|
||||
key_name: Option<String>,
|
||||
/// CA certificate name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "ca.pem",
|
||||
value_name = "CA_NAME"
|
||||
)]
|
||||
ca_name: Option<String>,
|
||||
|
||||
// Separate cert and key file inputs, required if not using zip input
|
||||
/// Certificate file path
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "key",
|
||||
conflicts_with = "zip",
|
||||
value_name = "CERT_FILE"
|
||||
)]
|
||||
cert: Option<String>,
|
||||
|
||||
/// Key file path
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "cert",
|
||||
conflicts_with = "zip",
|
||||
value_name = "KEY_FILE"
|
||||
)]
|
||||
key: Option<String>,
|
||||
|
||||
/// Master CA certificate file path for verifying master identity, optional if the CA certificate is already trusted by the system
|
||||
/// This is required if the master server uses a self-signed certificate that is not trusted by the system
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
conflicts_with = "zip",
|
||||
value_name = "CA_CERT_FILE"
|
||||
)]
|
||||
ca_cert: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_import_certs_with_zip_defaults() {
|
||||
let parsed = ImportCertsCommand::try_parse_from(["import-certs", "bundle.zip"]);
|
||||
assert!(parsed.is_ok());
|
||||
|
||||
let parsed = parsed.ok();
|
||||
assert!(parsed.is_some());
|
||||
let parsed = parsed.unwrap_or_else(|| unreachable!());
|
||||
|
||||
assert_eq!(parsed.zip.as_deref(), Some("bundle.zip"));
|
||||
assert_eq!(parsed.cert_name.as_deref(), Some("cert.pem"));
|
||||
assert_eq!(parsed.key_name.as_deref(), Some("key.pem"));
|
||||
assert_eq!(parsed.ca_name.as_deref(), Some("ca.pem"));
|
||||
assert!(parsed.cert.is_none());
|
||||
assert!(parsed.key.is_none());
|
||||
assert!(parsed.ca_cert.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_import_certs_with_separate_files() {
|
||||
let parsed = ImportCertsCommand::try_parse_from([
|
||||
"import-certs",
|
||||
"--cert",
|
||||
"agent.crt",
|
||||
"--key",
|
||||
"agent.key",
|
||||
"--ca-cert",
|
||||
"ca.crt",
|
||||
]);
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_conflicting_zip_and_separate_inputs() {
|
||||
let parsed = ImportCertsCommand::try_parse_from([
|
||||
"import-certs",
|
||||
"bundle.zip",
|
||||
"--cert",
|
||||
"agent.crt",
|
||||
"--key",
|
||||
"agent.key",
|
||||
]);
|
||||
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod import_certs;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
@@ -13,78 +15,14 @@ pub struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
#[command(about = "Import certificates for agent from zip file or separate cert and key files")]
|
||||
ImportCerts {
|
||||
// Zip file input, mutually exclusive with separate cert and key file inputs
|
||||
/// Zip file containing ca.pem cert.pem and key.pem
|
||||
#[arg(value_name = "ZIP_FILE", group = "input_source")]
|
||||
zip: Option<String>,
|
||||
/// Certificate name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "cert.pem",
|
||||
value_name = "CERT_NAME"
|
||||
)]
|
||||
cert_name: Option<String>,
|
||||
/// Key name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "key.pem",
|
||||
value_name = "KEY_NAME"
|
||||
)]
|
||||
key_name: Option<String>,
|
||||
/// CA certificate name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "ca.pem",
|
||||
value_name = "CA_NAME"
|
||||
)]
|
||||
ca_name: Option<String>,
|
||||
|
||||
// Separate cert and key file inputs, required if not using zip input
|
||||
/// Certificate file path
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "key",
|
||||
conflicts_with = "zip",
|
||||
value_name = "CERT_FILE"
|
||||
)]
|
||||
cert: Option<String>,
|
||||
|
||||
/// Key file path
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "cert",
|
||||
conflicts_with = "zip",
|
||||
value_name = "KEY_FILE"
|
||||
)]
|
||||
key: Option<String>,
|
||||
|
||||
/// Master CA certificate file path for verifying master identity, optional if the CA certificate is already trusted by the system
|
||||
/// This is required if the master server uses a self-signed certificate that is not trusted by the system
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
conflicts_with = "zip",
|
||||
value_name = "CA_CERT_FILE"
|
||||
)]
|
||||
ca_cert: Option<String>,
|
||||
},
|
||||
ImportCerts(import_certs::ImportCertsCommand),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::{Cli, Commands};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_serve_flag_without_subcommand() {
|
||||
@@ -98,65 +36,4 @@ mod tests {
|
||||
assert!(parsed.serve);
|
||||
assert!(parsed.command.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_import_certs_with_zip_defaults() {
|
||||
let parsed = Cli::try_parse_from(["nxmesh-agent", "import-certs", "bundle.zip"]);
|
||||
assert!(parsed.is_ok());
|
||||
|
||||
let parsed = parsed.ok();
|
||||
assert!(parsed.is_some());
|
||||
let parsed = parsed.unwrap_or_else(|| unreachable!());
|
||||
|
||||
match parsed.command {
|
||||
Some(Commands::ImportCerts {
|
||||
zip,
|
||||
cert_name,
|
||||
key_name,
|
||||
ca_name,
|
||||
cert,
|
||||
key,
|
||||
ca_cert,
|
||||
}) => {
|
||||
assert_eq!(zip.as_deref(), Some("bundle.zip"));
|
||||
assert_eq!(cert_name.as_deref(), Some("cert.pem"));
|
||||
assert_eq!(key_name.as_deref(), Some("key.pem"));
|
||||
assert_eq!(ca_name.as_deref(), Some("ca.pem"));
|
||||
assert!(cert.is_none());
|
||||
assert!(key.is_none());
|
||||
assert!(ca_cert.is_none());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_import_certs_with_separate_files() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"nxmesh-agent",
|
||||
"import-certs",
|
||||
"--cert",
|
||||
"agent.crt",
|
||||
"--key",
|
||||
"agent.key",
|
||||
"--ca-cert",
|
||||
"ca.crt",
|
||||
]);
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_conflicting_zip_and_separate_inputs() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"nxmesh-agent",
|
||||
"import-certs",
|
||||
"bundle.zip",
|
||||
"--cert",
|
||||
"agent.crt",
|
||||
"--key",
|
||||
"agent.key",
|
||||
]);
|
||||
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,561 +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,
|
||||
#[serde(default)]
|
||||
pub nginx: NginxSettings,
|
||||
}
|
||||
|
||||
/// gRPC client settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrpcSettings {
|
||||
pub connection_string: String,
|
||||
pub m_auth: MAuthSettings,
|
||||
#[serde(default)]
|
||||
pub cors: Option<CorsSettings>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_methods: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_headers: Vec<String>,
|
||||
#[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, Default)]
|
||||
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<String>,
|
||||
// commands
|
||||
#[serde(default = "default_nginx_reload_command")]
|
||||
pub override_nginx_reload_command: Vec<String>,
|
||||
#[serde(default = "default_nginx_test_command")]
|
||||
pub override_nginx_test_command: Vec<String>,
|
||||
// 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()?;
|
||||
self.nginx.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Load settings from config files and environment
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
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)?;
|
||||
|
||||
settings.nginx.validate().map_err(ConfigError::Message)?;
|
||||
|
||||
// replace binary path template in commands with actual binary path, if the template is present
|
||||
settings
|
||||
.nginx
|
||||
.override_nginx_reload_command
|
||||
.iter_mut()
|
||||
.for_each(|cmd| {
|
||||
*cmd = cmd.replace(
|
||||
NGINX_BINARY_PATH_TEMPLATE,
|
||||
&settings
|
||||
.nginx
|
||||
.nginx_binary_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()),
|
||||
);
|
||||
});
|
||||
settings
|
||||
.nginx
|
||||
.override_nginx_test_command
|
||||
.iter_mut()
|
||||
.for_each(|cmd| {
|
||||
*cmd = cmd.replace(
|
||||
NGINX_BINARY_PATH_TEMPLATE,
|
||||
&settings
|
||||
.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<String> {
|
||||
vec![
|
||||
NGINX_BINARY_PATH_TEMPLATE.to_string(),
|
||||
"-s".to_string(),
|
||||
"reload".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn default_nginx_test_command() -> Vec<String> {
|
||||
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<LevelFilter, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
LevelFilter::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn serialize_level_filter<S>(level: &LevelFilter, serializer: S) -> Result<S::Ok, S::Error>
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<Settings>();
|
||||
assert_send_sync::<GrpcSettings>();
|
||||
assert_send_sync::<TLSSettings>();
|
||||
assert_send_sync::<CorsSettings>();
|
||||
assert_send_sync::<LogSettings>();
|
||||
assert_send_sync::<NginxSettings>();
|
||||
}
|
||||
|
||||
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::<Wrapper>(&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);
|
||||
}
|
||||
}
|
||||
166
apps/nxmesh-agent/src/config/settings/auth.rs
Normal file
166
apps/nxmesh-agent/src/config/settings/auth.rs
Normal file
@@ -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<T: Send + Sync>() {}
|
||||
assert_send_sync::<TLSSettings>();
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
34
apps/nxmesh-agent/src/config/settings/cors.rs
Normal file
34
apps/nxmesh-agent/src/config/settings/cors.rs
Normal file
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_methods: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_headers: Vec<String>,
|
||||
#[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<T: Send + Sync>() {}
|
||||
assert_send_sync::<CorsSettings>();
|
||||
}
|
||||
}
|
||||
56
apps/nxmesh-agent/src/config/settings/grpc.rs
Normal file
56
apps/nxmesh-agent/src/config/settings/grpc.rs
Normal file
@@ -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<CorsSettings>,
|
||||
}
|
||||
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<GrpcSettings>();
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
82
apps/nxmesh-agent/src/config/settings/log.rs
Normal file
82
apps/nxmesh-agent/src/config/settings/log.rs
Normal file
@@ -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<LevelFilter, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
LevelFilter::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn serialize_level_filter<S>(level: &LevelFilter, serializer: S) -> Result<S::Ok, S::Error>
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<LogSettings>();
|
||||
}
|
||||
#[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::<Wrapper>(&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);
|
||||
}
|
||||
}
|
||||
74
apps/nxmesh-agent/src/config/settings/mod.rs
Normal file
74
apps/nxmesh-agent/src/config/settings/mod.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub nginx: NginxSettings,
|
||||
}
|
||||
|
||||
impl Validate for Settings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
self.grpc.validate()?;
|
||||
self.nginx.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Load settings from config files and environment
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
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)?;
|
||||
|
||||
settings.nginx.validate().map_err(ConfigError::Message)?;
|
||||
settings.nginx.transform_commands();
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ensure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<Settings>();
|
||||
}
|
||||
}
|
||||
280
apps/nxmesh-agent/src/config/settings/nginx.rs
Normal file
280
apps/nxmesh-agent/src/config/settings/nginx.rs
Normal file
@@ -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, Default)]
|
||||
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<String>,
|
||||
// commands
|
||||
#[serde(default = "default_nginx_reload_command")]
|
||||
pub override_nginx_reload_command: Vec<String>,
|
||||
#[serde(default = "default_nginx_test_command")]
|
||||
pub override_nginx_test_command: Vec<String>,
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
vec![
|
||||
NGINX_BINARY_PATH_TEMPLATE.to_string(),
|
||||
"-s".to_string(),
|
||||
"reload".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn default_nginx_test_command() -> Vec<String> {
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<NginxSettings>();
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -63,14 +63,14 @@ async fn main() {
|
||||
// send a dummy heartbeat to verify the connection is working
|
||||
let mut client = master_connector.get_client().lock().await.clone();
|
||||
|
||||
let request = nxmesh_proto::HealthReport {
|
||||
let request = nxmesh_proto::TestRequest {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match client.report_health(request).await {
|
||||
Ok(_) => info!("Successfully sent health report to master."),
|
||||
match client.connection_test(request).await {
|
||||
Ok(_) => info!("Successfully sent connection test to master."),
|
||||
Err(e) => {
|
||||
error!("Failed to send health report to master: {}", e);
|
||||
error!("Failed to send connection test to master: {}", e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CorsSettings>,
|
||||
}
|
||||
|
||||
/// Database connection settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseSettings {
|
||||
pub url: String,
|
||||
pub max_connections: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<CorsSettings>,
|
||||
}
|
||||
|
||||
/// 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<Ia5String>,
|
||||
#[serde(default)]
|
||||
pub san_ip: Vec<IpAddr>,
|
||||
#[serde(default)]
|
||||
cert_path: Option<String>,
|
||||
#[serde(default)]
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// CORS settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CorsSettings {
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_methods: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_headers: Vec<String>,
|
||||
#[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<Self, ConfigError> {
|
||||
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<LevelFilter, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
LevelFilter::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn serialize_level_filter<S>(level: &LevelFilter, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&level.to_string())
|
||||
}
|
||||
|
||||
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::<Settings>();
|
||||
assert_send_sync::<ServerSettings>();
|
||||
assert_send_sync::<DatabaseSettings>();
|
||||
assert_send_sync::<GrpcSettings>();
|
||||
assert_send_sync::<AuthSettings>();
|
||||
assert_send_sync::<CertificateSettings>();
|
||||
assert_send_sync::<CorsSettings>();
|
||||
assert_send_sync::<LogSettings>();
|
||||
}
|
||||
|
||||
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<Wrapper, _> = 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<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);
|
||||
}
|
||||
}
|
||||
58
apps/nxmesh-master/src/config/settings/auth.rs
Normal file
58
apps/nxmesh-master/src/config/settings/auth.rs
Normal file
@@ -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<T: Send + Sync>() {}
|
||||
assert_send_sync::<AuthSettings>();
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
34
apps/nxmesh-master/src/config/settings/cors.rs
Normal file
34
apps/nxmesh-master/src/config/settings/cors.rs
Normal file
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_methods: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_headers: Vec<String>,
|
||||
#[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<T: Send + Sync>() {}
|
||||
assert_send_sync::<CorsSettings>();
|
||||
}
|
||||
}
|
||||
48
apps/nxmesh-master/src/config/settings/database.rs
Normal file
48
apps/nxmesh-master/src/config/settings/database.rs
Normal file
@@ -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<u32>,
|
||||
}
|
||||
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<DatabaseSettings>();
|
||||
}
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
53
apps/nxmesh-master/src/config/settings/grpc.rs
Normal file
53
apps/nxmesh-master/src/config/settings/grpc.rs
Normal file
@@ -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<CorsSettings>,
|
||||
}
|
||||
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<GrpcSettings>();
|
||||
}
|
||||
}
|
||||
81
apps/nxmesh-master/src/config/settings/log.rs
Normal file
81
apps/nxmesh-master/src/config/settings/log.rs
Normal file
@@ -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<LevelFilter, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
LevelFilter::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn serialize_level_filter<S>(level: &LevelFilter, serializer: S) -> Result<S::Ok, S::Error>
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<LogSettings>();
|
||||
}
|
||||
|
||||
#[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<Wrapper, _> = serde_json::from_str(&encoded);
|
||||
assert!(decoded.is_ok());
|
||||
let decoded = decoded.unwrap_or(Wrapper {
|
||||
level: LevelFilter::ERROR,
|
||||
});
|
||||
assert_eq!(decoded.level, LevelFilter::DEBUG);
|
||||
}
|
||||
}
|
||||
75
apps/nxmesh-master/src/config/settings/mod.rs
Normal file
75
apps/nxmesh-master/src/config/settings/mod.rs
Normal file
@@ -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<Self, ConfigError> {
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<Settings>();
|
||||
}
|
||||
}
|
||||
103
apps/nxmesh-master/src/config/settings/server.rs
Normal file
103
apps/nxmesh-master/src/config/settings/server.rs
Normal file
@@ -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<CorsSettings>,
|
||||
}
|
||||
|
||||
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<T: Send + Sync>() {}
|
||||
assert_send_sync::<ServerSettings>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,11 @@ pub async fn get_fallback_handler() -> Result<axum::response::Html<Vec<u8>>, axu
|
||||
}
|
||||
|
||||
fn get_index_html() -> Option<Vec<u8>> {
|
||||
FrontendAssets::get(INDEX_HTML).map(|asset| asset.data.as_ref().to_owned())
|
||||
// Try root index.html first, then fall back to client/index.html when assets
|
||||
// are packaged under the `client/` subfolder.
|
||||
FrontendAssets::get(INDEX_HTML)
|
||||
.or_else(|| FrontendAssets::get(&format!("client/{}", INDEX_HTML)))
|
||||
.map(|asset| asset.data.as_ref().to_owned())
|
||||
}
|
||||
|
||||
async fn get_file_handler(
|
||||
@@ -49,7 +53,10 @@ async fn get_file_handler(
|
||||
path
|
||||
};
|
||||
|
||||
match FrontendAssets::get(&file_path) {
|
||||
// Try direct lookup first, then fallback to the `client/` subfolder.
|
||||
match FrontendAssets::get(&file_path)
|
||||
.or_else(|| FrontendAssets::get(&format!("client/{}", file_path)))
|
||||
{
|
||||
Some(asset) => {
|
||||
let content_type = mime_guess::from_path(&file_path).first_or_octet_stream();
|
||||
let response = axum::response::Response::builder()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use nxmesh_proto::{
|
||||
Ack, AgentMessage, HealthReport, MasterMessage, MetricsBatch,
|
||||
agent_service_server::AgentService,
|
||||
};
|
||||
use tracing::warn;
|
||||
use nxmesh_proto::{AgentMessage, MasterMessage, agent_service_server::AgentService};
|
||||
|
||||
pub mod repo;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AgentServerService {}
|
||||
@@ -25,30 +23,13 @@ impl AgentService for AgentServerService {
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[doc = " ReportHealth sends a health report to the master"]
|
||||
#[allow(
|
||||
mismatched_lifetime_syntaxes,
|
||||
clippy::type_complexity,
|
||||
clippy::type_repetition_in_bounds
|
||||
)]
|
||||
async fn report_health(
|
||||
async fn connection_test(
|
||||
&self,
|
||||
request: tonic::Request<HealthReport>,
|
||||
) -> Result<tonic::Response<Ack>, tonic::Status> {
|
||||
warn!("Received health report: {:?}", request.get_ref());
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[doc = " ReportMetrics sends metrics batch to the master"]
|
||||
#[allow(
|
||||
mismatched_lifetime_syntaxes,
|
||||
clippy::type_complexity,
|
||||
clippy::type_repetition_in_bounds
|
||||
)]
|
||||
async fn report_metrics(
|
||||
&self,
|
||||
request: tonic::Request<MetricsBatch>,
|
||||
) -> Result<tonic::Response<Ack>, tonic::Status> {
|
||||
todo!()
|
||||
_request: tonic::Request<nxmesh_proto::TestRequest>,
|
||||
) -> Result<tonic::Response<nxmesh_proto::TestResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(nxmesh_proto::TestResponse {
|
||||
success: true,
|
||||
error_message: String::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user