refactor: reorganize settings into separate modules for improved structure and maintainability
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user