Merge branch 'master' into feature/nginx-handler
This commit is contained in:
39
.devcontainer/devcontainer-lock.json
Normal file
39
.devcontainer/devcontainer-lock.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers-extra/features/act": {
|
||||||
|
"version": "1.0.15",
|
||||||
|
"resolved": "ghcr.io/devcontainers-extra/features/act@sha256:db4a2194930d1f7ec62822d4f600dd2fa4aff3c33b98cdb0b578b64ffb10924c",
|
||||||
|
"integrity": "sha256:db4a2194930d1f7ec62822d4f600dd2fa4aff3c33b98cdb0b578b64ffb10924c"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers-extra/features/bun": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "ghcr.io/devcontainers-extra/features/bun@sha256:0624284ecaead9dd4c6654616a7f939cfa4ebcbc60593700a74e35b1767befa5",
|
||||||
|
"integrity": "sha256:0624284ecaead9dd4c6654616a7f939cfa4ebcbc60593700a74e35b1767befa5"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||||
|
"version": "2.5.9",
|
||||||
|
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
|
||||||
|
"integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
|
"version": "2.17.0",
|
||||||
|
"resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c",
|
||||||
|
"integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6",
|
||||||
|
"integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/rust:1": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "ghcr.io/devcontainers/features/rust@sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c",
|
||||||
|
"integrity": "sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c"
|
||||||
|
},
|
||||||
|
"ghcr.io/guiyomh/features/just:0": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "ghcr.io/guiyomh/features/just@sha256:8311dff976bd153a54a879021353a7e149963e580022b25af49c45cfc5f13bec",
|
||||||
|
"integrity": "sha256:8311dff976bd153a54a879021353a7e149963e580022b25af49c45cfc5f13bec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,14 @@
|
|||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"ms-azuretools.vscode-docker",
|
"ms-azuretools.vscode-docker",
|
||||||
"nefrob.vscode-just-syntax"
|
"nefrob.vscode-just-syntax",
|
||||||
|
"zxh404.vscode-proto3",
|
||||||
|
"mhutchie.git-graph",
|
||||||
|
"qwtel.sqlite-viewer",
|
||||||
|
"streetsidesoftware.code-spell-checker",
|
||||||
|
"christian-kohler.npm-intellisense",
|
||||||
|
"christian-kohler.path-intellisense",
|
||||||
|
"redhat.vscode-yaml"
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"rust-analyzer.cargo.features": "all",
|
"rust-analyzer.cargo.features": "all",
|
||||||
|
|||||||
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};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
pub mod import_certs;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
@@ -13,78 +15,14 @@ pub struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Commands {
|
pub enum Commands {
|
||||||
#[command(about = "Import certificates for agent from zip file or separate cert and key files")]
|
ImportCerts(import_certs::ImportCertsCommand),
|
||||||
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>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use super::{Cli, Commands};
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_serve_flag_without_subcommand() {
|
fn parses_serve_flag_without_subcommand() {
|
||||||
@@ -98,65 +36,4 @@ mod tests {
|
|||||||
assert!(parsed.serve);
|
assert!(parsed.serve);
|
||||||
assert!(parsed.command.is_none());
|
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
|
// send a dummy heartbeat to verify the connection is working
|
||||||
let mut client = master_connector.get_client().lock().await.clone();
|
let mut client = master_connector.get_client().lock().await.clone();
|
||||||
|
|
||||||
let request = nxmesh_proto::HealthReport {
|
let request = nxmesh_proto::TestRequest {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
match client.report_health(request).await {
|
match client.connection_test(request).await {
|
||||||
Ok(_) => info!("Successfully sent health report to master."),
|
Ok(_) => info!("Successfully sent connection test to master."),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to send health report to master: {}", e);
|
error!("Failed to send connection test to master: {}", e);
|
||||||
exit(1);
|
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>> {
|
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(
|
async fn get_file_handler(
|
||||||
@@ -49,7 +53,10 @@ async fn get_file_handler(
|
|||||||
path
|
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) => {
|
Some(asset) => {
|
||||||
let content_type = mime_guess::from_path(&file_path).first_or_octet_stream();
|
let content_type = mime_guess::from_path(&file_path).first_or_octet_stream();
|
||||||
let response = axum::response::Response::builder()
|
let response = axum::response::Response::builder()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use nxmesh_proto::{
|
use nxmesh_proto::{AgentMessage, MasterMessage, agent_service_server::AgentService};
|
||||||
Ack, AgentMessage, HealthReport, MasterMessage, MetricsBatch,
|
|
||||||
agent_service_server::AgentService,
|
pub mod repo;
|
||||||
};
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AgentServerService {}
|
pub struct AgentServerService {}
|
||||||
@@ -25,30 +23,13 @@ impl AgentService for AgentServerService {
|
|||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[doc = " ReportHealth sends a health report to the master"]
|
async fn connection_test(
|
||||||
#[allow(
|
|
||||||
mismatched_lifetime_syntaxes,
|
|
||||||
clippy::type_complexity,
|
|
||||||
clippy::type_repetition_in_bounds
|
|
||||||
)]
|
|
||||||
async fn report_health(
|
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<HealthReport>,
|
_request: tonic::Request<nxmesh_proto::TestRequest>,
|
||||||
) -> Result<tonic::Response<Ack>, tonic::Status> {
|
) -> Result<tonic::Response<nxmesh_proto::TestResponse>, tonic::Status> {
|
||||||
warn!("Received health report: {:?}", request.get_ref());
|
Ok(tonic::Response::new(nxmesh_proto::TestResponse {
|
||||||
todo!()
|
success: true,
|
||||||
}
|
error_message: String::new(),
|
||||||
|
}))
|
||||||
#[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!()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,106 +11,53 @@ service AgentService {
|
|||||||
// Stream establishes a persistent connection for real-time communication
|
// Stream establishes a persistent connection for real-time communication
|
||||||
rpc Stream(stream AgentMessage) returns (stream MasterMessage);
|
rpc Stream(stream AgentMessage) returns (stream MasterMessage);
|
||||||
|
|
||||||
// ReportHealth sends a health report to the master
|
rpc ConnectionTest(TestRequest) returns (TestResponse);
|
||||||
rpc ReportHealth(HealthReport) returns (Ack);
|
}
|
||||||
|
|
||||||
// ReportMetrics sends metrics batch to the master
|
message TestRequest {
|
||||||
rpc ReportMetrics(MetricsBatch) returns (Ack);
|
// no fields needed for test request
|
||||||
|
}
|
||||||
|
|
||||||
|
message TestResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string error_message = 2; // if success is false, this field should contain the error message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages sent from master to agent
|
||||||
|
message MasterMessage {
|
||||||
|
int64 timestamp = 1;
|
||||||
|
string message_id = 2;
|
||||||
|
oneof payload {
|
||||||
|
// requests
|
||||||
|
ConfigUpdate config_update = 3;
|
||||||
|
Command command = 4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages sent from agent to master
|
// Messages sent from agent to master
|
||||||
message AgentMessage {
|
message AgentMessage {
|
||||||
string agent_id = 1;
|
string agent_id = 1;
|
||||||
int64 timestamp = 2;
|
int64 timestamp = 2;
|
||||||
|
string message_id = 3;
|
||||||
oneof payload {
|
oneof payload {
|
||||||
RegistrationRequest registration = 3;
|
// responses
|
||||||
HealthReport health = 4;
|
ConfigUpdateResult config_update_result = 6;
|
||||||
ConfigStatus config_status = 5;
|
CommandResult command_result = 7;
|
||||||
MetricsBatch metrics = 6;
|
|
||||||
LogBatch logs = 7;
|
|
||||||
Event event = 8;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages sent from master to agent
|
//
|
||||||
message MasterMessage {
|
//
|
||||||
int64 timestamp = 1;
|
//
|
||||||
oneof payload {
|
|
||||||
RegistrationResponse registration_response = 2;
|
|
||||||
ConfigUpdate config_update = 3;
|
|
||||||
Command command = 4;
|
|
||||||
Ack ack = 5;
|
|
||||||
Error error = 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registration
|
// ConfigUpdate represents a request from master to agent to update the configuration
|
||||||
message RegistrationRequest {
|
|
||||||
string hostname = 1;
|
|
||||||
string ip_address = 2;
|
|
||||||
string version = 3;
|
|
||||||
repeated string capabilities = 4;
|
|
||||||
map<string, string> labels = 5;
|
|
||||||
DeploymentMode deployment_mode = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
message RegistrationResponse {
|
|
||||||
string agent_id = 1;
|
|
||||||
bool success = 2;
|
|
||||||
string error_message = 3;
|
|
||||||
int64 heartbeat_interval_seconds = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum DeploymentMode {
|
|
||||||
DEPLOYMENT_MODE_UNSPECIFIED = 0;
|
|
||||||
DOCKER_SIDECAR = 1;
|
|
||||||
KUBERNETES_SIDECAR = 2;
|
|
||||||
STANDALONE = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health Reporting
|
|
||||||
message HealthReport {
|
|
||||||
NginxStatus nginx = 1;
|
|
||||||
SystemMetrics system = 2;
|
|
||||||
string config_checksum = 3;
|
|
||||||
int64 config_version = 4;
|
|
||||||
repeated Alert alerts = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
message NginxStatus {
|
|
||||||
bool is_running = 1;
|
|
||||||
uint32 pid = 2;
|
|
||||||
uint64 uptime_seconds = 3;
|
|
||||||
uint32 active_connections = 4;
|
|
||||||
uint64 total_requests = 5;
|
|
||||||
float requests_per_second = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SystemMetrics {
|
|
||||||
float cpu_percent = 1;
|
|
||||||
uint64 memory_used_bytes = 2;
|
|
||||||
uint64 memory_total_bytes = 3;
|
|
||||||
uint64 disk_used_bytes = 4;
|
|
||||||
uint64 disk_total_bytes = 5;
|
|
||||||
float load_average_1m = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Alert {
|
|
||||||
string id = 1;
|
|
||||||
string severity = 2; // info, warning, error, critical
|
|
||||||
string message = 3;
|
|
||||||
int64 timestamp = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
message ConfigUpdate {
|
message ConfigUpdate {
|
||||||
string config_id = 1;
|
string config_id = 1; // unique identifier for this config update
|
||||||
int64 version = 2;
|
string version = 2;
|
||||||
// The root config is the main nginx.conf file, this file will be used as the entry point for nginx configuration. The content of this file should include references to other config files if needed. The agent will write this root config to the nginx config directory and use it to reload nginx.
|
// The root config is the main nginx.conf file, this file will be used as the entry point for nginx configuration. The content of this file should include references to other config files if needed. The agent will write this root config to the nginx config directory and use it to reload nginx.
|
||||||
ConfigContent root_config = 3;
|
ConfigContent root_config = 3;
|
||||||
// The other config files that are referenced by the root config, e.g. "site.conf", "private/example.com.conf". If the root config does not reference any other config files, this field can be left empty. The agent will write these config files to the nginx config directory and ensure they are included in the root config.
|
// The other config files that are referenced by the root config, e.g. "site.conf", "private/example.com.conf". If the root config does not reference any other config files, this field can be left empty. The agent will write these config files to the nginx config directory and ensure they are included in the root config.
|
||||||
repeated ConfigContent configs = 4;
|
repeated ConfigContent configs = 4;
|
||||||
repeated CertificateContent certificates = 5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message ConfigContent {
|
message ConfigContent {
|
||||||
@@ -119,113 +66,53 @@ message ConfigContent {
|
|||||||
string content = 2;
|
string content = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CertificateContent {
|
message ConfigUpdateResult {
|
||||||
string id = 1;
|
string config_id = 1; // should match the config_id in ConfigUpdate
|
||||||
// relative path from other config files, e.g. "certs/example.com.pem"
|
bool success = 2;
|
||||||
string path = 2;
|
ConfigUpdateError error_message = 3; // if success is false, this field should contain the error message
|
||||||
string certificate_pem = 3;
|
|
||||||
string private_key_pem = 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message ConfigStatus {
|
enum ConfigUpdateError {
|
||||||
string config_id = 1;
|
UNKNOWN = 0;
|
||||||
int64 version = 2;
|
INVALID_CONFIG = 1; // the config content is invalid, e.g. syntax error
|
||||||
ConfigApplyStatus status = 3;
|
WRITE_FAILED = 2; // failed to write the config file to disk
|
||||||
string error_message = 4;
|
RELOAD_FAILED = 3; // failed to reload nginx with the new config
|
||||||
int64 applied_at = 5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ConfigApplyStatus {
|
//
|
||||||
CONFIG_APPLY_STATUS_UNSPECIFIED = 0;
|
//
|
||||||
PENDING = 1;
|
//
|
||||||
VALIDATING = 2;
|
|
||||||
APPLYING = 3;
|
|
||||||
SUCCESS = 4;
|
|
||||||
FAILED = 5;
|
|
||||||
ROLLED_BACK = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metrics
|
// Command represents a request from master to agent to execute a command, e.g. "reload", "test"
|
||||||
message MetricsBatch {
|
|
||||||
int64 timestamp = 1;
|
|
||||||
repeated Metric metrics = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Metric {
|
|
||||||
string name = 1;
|
|
||||||
double value = 2;
|
|
||||||
int64 timestamp = 3;
|
|
||||||
map<string, string> labels = 4;
|
|
||||||
MetricType type = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MetricType {
|
|
||||||
METRIC_TYPE_UNSPECIFIED = 0;
|
|
||||||
GAUGE = 1;
|
|
||||||
COUNTER = 2;
|
|
||||||
HISTOGRAM = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logs
|
|
||||||
message LogBatch {
|
|
||||||
repeated LogEntry entries = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message LogEntry {
|
|
||||||
int64 timestamp = 1;
|
|
||||||
string level = 2;
|
|
||||||
string message = 3;
|
|
||||||
map<string, string> fields = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commands
|
|
||||||
message Command {
|
message Command {
|
||||||
string command_id = 1;
|
|
||||||
oneof command {
|
oneof command {
|
||||||
ReloadCommand reload = 2;
|
ReloadCommand reload = 1;
|
||||||
RestartCommand restart = 3;
|
TestCommand test = 2;
|
||||||
StopCommand stop = 4;
|
|
||||||
GetStatusCommand get_status = 5;
|
|
||||||
ValidateConfigCommand validate_config = 6;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message ReloadCommand {
|
message ReloadCommand {
|
||||||
bool graceful = 1;
|
// no additional fields needed for reload command
|
||||||
}
|
}
|
||||||
|
|
||||||
message RestartCommand {
|
message TestCommand {
|
||||||
bool force = 1;
|
// no additional fields needed for test command
|
||||||
}
|
}
|
||||||
|
|
||||||
message StopCommand {
|
message CommandResult {
|
||||||
bool graceful = 1;
|
oneof result {
|
||||||
uint32 timeout_seconds = 2;
|
ReloadResult reload_result = 1;
|
||||||
|
TestResult test_result = 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetStatusCommand {}
|
message ReloadResult {
|
||||||
|
bool success = 1;
|
||||||
message ValidateConfigCommand {
|
string error_message = 2; // if success is false, this field should contain the error message
|
||||||
string config_content = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Events
|
message TestResult {
|
||||||
message Event {
|
bool success = 1;
|
||||||
string event_id = 1;
|
string error_message = 2; // if success is false, this field should contain the error message
|
||||||
string event_type = 2;
|
|
||||||
int64 timestamp = 3;
|
|
||||||
map<string, string> data = 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common messages
|
|
||||||
message Ack {
|
|
||||||
string message_id = 1;
|
|
||||||
bool success = 2;
|
|
||||||
string error_message = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Error {
|
|
||||||
string code = 1;
|
|
||||||
string message = 2;
|
|
||||||
map<string, string> details = 3;
|
|
||||||
}
|
|
||||||
|
|||||||
2
justfile
2
justfile
@@ -38,7 +38,7 @@ setup-frontend:
|
|||||||
act *ARGS:
|
act *ARGS:
|
||||||
# run act with custom secret-file
|
# run act with custom secret-file
|
||||||
@echo "🎬 Running act with custom secrets file..."
|
@echo "🎬 Running act with custom secrets file..."
|
||||||
act --env-file .github/.env --secret-file .github/.secrets.env --var-file .github/.var.env --network host {{ ARGS }}
|
act --env-file .github/.env --secret-file .github/.secrets.env --var-file .github/.var.env --network host --artifact-server-path ./.act/.artifacts {{ ARGS }}
|
||||||
|
|
||||||
# Start all services for development
|
# Start all services for development
|
||||||
dev:
|
dev:
|
||||||
|
|||||||
Reference in New Issue
Block a user