1 Commits

Author SHA1 Message Date
GW_MC
ffae46f906 feat: refactor nxmesh-frontend to use React Router with new layout components
- Updated package.json to replace Vite with React Router for development and build scripts.
- Added react-router.config.ts for configuration settings.
- Created a new App component with a dashboard layout including header, sidebar, and footer.
- Enhanced App.css with a professional theme and responsive design.
- Removed old App.tsx and index.css files.
- Introduced RootLayout component to encapsulate layout structure.
- Developed Header, Footer, and Sidebar components for consistent layout.
- Implemented routes for home and dashboard with key metrics and activity sections.
- Updated vite.config.ts to integrate React Router and adjust SSR settings.
2026-05-06 10:48:39 +00:00
95 changed files with 2647 additions and 6401 deletions

View File

@@ -1,39 +0,0 @@
{
"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"
}
}
}

View File

@@ -43,14 +43,7 @@
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ms-azuretools.vscode-docker",
"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"
"nefrob.vscode-just-syntax"
],
"settings": {
"rust-analyzer.cargo.features": "all",

44
Cargo.lock generated
View File

@@ -1091,20 +1091,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
@@ -1457,17 +1443,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "fs4"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
dependencies = [
"rustix",
"tokio",
"windows-sys 0.59.0",
]
[[package]]
name = "funty"
version = "2.0.0"
@@ -2583,13 +2558,10 @@ dependencies = [
name = "nxmesh-agent"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"clap",
"config",
"dashmap",
"fs4",
"futures",
"hex",
"hostname",
@@ -2604,7 +2576,6 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-test",
"tokio-util",
"toml",
"tonic",
"tracing",
@@ -2629,7 +2600,6 @@ dependencies = [
name = "nxmesh-master"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-stream",
"async-trait",
@@ -2658,7 +2628,6 @@ dependencies = [
"thiserror",
"time",
"tokio",
"tokio-stream",
"tokio-test",
"toml",
"tonic",
@@ -2703,9 +2672,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.4"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
@@ -5411,15 +5380,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"

View File

@@ -56,6 +56,9 @@ futures = "0.3"
toml = "0.9"
config = "0.15"
# HTTP client
reqwest = { version = "0.13.2", default-features = false, features = ["json"] }
# Crypto
sha2 = "0.10"
hex = "0.4"

View File

@@ -33,7 +33,6 @@ tonic.workspace = true
async-trait.workspace = true
futures.workspace = true
tokio-stream.workspace = true
tokio-util = "0.7"
# Config
config.workspace = true
@@ -57,9 +56,6 @@ zip = { workspace = true }
# CLI
clap = { workspace = true, features = ["derive"] }
anyhow = { version = "1.0.102", features = ["backtrace"] }
fs4 = { version = "0.13.1", features = ["tokio"] }
dashmap = "6.2.1"
[dev-dependencies]
tokio-test.workspace = true

View File

@@ -1,119 +0,0 @@
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());
}
}

View File

@@ -1,7 +1,5 @@
use clap::{Parser, Subcommand};
pub mod import_certs;
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
@@ -15,14 +13,78 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
ImportCerts(import_certs::ImportCertsCommand),
#[command(about = "Import certificates for agent from zip file or separate cert and key files")]
ImportCerts {
// Zip file input, mutually exclusive with separate cert and key file inputs
/// Zip file containing ca.pem cert.pem and key.pem
#[arg(value_name = "ZIP_FILE", group = "input_source")]
zip: Option<String>,
/// Certificate name in zip file, required if using zip input
#[arg(
long,
group = "input_source",
requires = "zip",
default_value = "cert.pem",
value_name = "CERT_NAME"
)]
cert_name: Option<String>,
/// Key name in zip file, required if using zip input
#[arg(
long,
group = "input_source",
requires = "zip",
default_value = "key.pem",
value_name = "KEY_NAME"
)]
key_name: Option<String>,
/// CA certificate name in zip file, required if using zip input
#[arg(
long,
group = "input_source",
requires = "zip",
default_value = "ca.pem",
value_name = "CA_NAME"
)]
ca_name: Option<String>,
// Separate cert and key file inputs, required if not using zip input
/// Certificate file path
#[arg(
long,
group = "input_source",
requires = "key",
conflicts_with = "zip",
value_name = "CERT_FILE"
)]
cert: Option<String>,
/// Key file path
#[arg(
long,
group = "input_source",
requires = "cert",
conflicts_with = "zip",
value_name = "KEY_FILE"
)]
key: Option<String>,
/// Master CA certificate file path for verifying master identity, optional if the CA certificate is already trusted by the system
/// This is required if the master server uses a self-signed certificate that is not trusted by the system
#[arg(
long,
group = "input_source",
conflicts_with = "zip",
value_name = "CA_CERT_FILE"
)]
ca_cert: Option<String>,
},
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
use super::{Cli, Commands};
#[test]
fn parses_serve_flag_without_subcommand() {
@@ -36,4 +98,65 @@ mod tests {
assert!(parsed.serve);
assert!(parsed.command.is_none());
}
#[test]
fn parses_import_certs_with_zip_defaults() {
let parsed = Cli::try_parse_from(["nxmesh-agent", "import-certs", "bundle.zip"]);
assert!(parsed.is_ok());
let parsed = parsed.ok();
assert!(parsed.is_some());
let parsed = parsed.unwrap_or_else(|| unreachable!());
match parsed.command {
Some(Commands::ImportCerts {
zip,
cert_name,
key_name,
ca_name,
cert,
key,
ca_cert,
}) => {
assert_eq!(zip.as_deref(), Some("bundle.zip"));
assert_eq!(cert_name.as_deref(), Some("cert.pem"));
assert_eq!(key_name.as_deref(), Some("key.pem"));
assert_eq!(ca_name.as_deref(), Some("ca.pem"));
assert!(cert.is_none());
assert!(key.is_none());
assert!(ca_cert.is_none());
}
_ => unreachable!(),
}
}
#[test]
fn rejects_import_certs_with_separate_files() {
let parsed = Cli::try_parse_from([
"nxmesh-agent",
"import-certs",
"--cert",
"agent.crt",
"--key",
"agent.key",
"--ca-cert",
"ca.crt",
]);
assert!(parsed.is_err());
}
#[test]
fn rejects_conflicting_zip_and_separate_inputs() {
let parsed = Cli::try_parse_from([
"nxmesh-agent",
"import-certs",
"bundle.zip",
"--cert",
"agent.crt",
"--key",
"agent.key",
]);
assert!(parsed.is_err());
}
}

View File

@@ -0,0 +1,560 @@
use config::{Config, ConfigError, Environment, File};
use serde::{Deserialize, Deserializer, Serialize};
use std::{os::unix::fs::PermissionsExt, str::FromStr};
use tracing::level_filters::LevelFilter;
const NGINX_BINARY_PATH_TEMPLATE: &str = "{{nginx_binary_path}}";
const NGINX_DEFAULT_BINARY: &str = "nginx";
type ValidationError = String;
trait Validate {
fn validate(&self) -> Result<(), ValidationError>;
}
/// Agent settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub grpc: GrpcSettings,
#[serde(default)]
pub log: LogSettings,
pub nginx: Option<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)]
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()?;
if let Some(nginx) = &self.nginx {
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)?;
if let Some(nginx) = &mut settings.nginx {
nginx.validate().map_err(ConfigError::Message)?;
// replace binary path template in commands with actual binary path, if the template is present
nginx
.override_nginx_reload_command
.iter_mut()
.for_each(|cmd| {
*cmd = cmd.replace(
NGINX_BINARY_PATH_TEMPLATE,
&nginx
.nginx_binary_path
.clone()
.unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()),
);
});
nginx
.override_nginx_test_command
.iter_mut()
.for_each(|cmd| {
*cmd = cmd.replace(
NGINX_BINARY_PATH_TEMPLATE,
&nginx
.nginx_binary_path
.clone()
.unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()),
);
});
}
Ok(settings)
}
}
impl Validate for GrpcSettings {
fn validate(&self) -> Result<(), ValidationError> {
if self.connection_string.is_empty() {
return Err("gRPC connection string cannot be empty".into());
}
self.m_auth.validate()?;
if let Some(cors) = &self.cors {
cors.validate()?;
}
Ok(())
}
}
impl Validate for MAuthSettings {
fn validate(&self) -> Result<(), ValidationError> {
match self {
MAuthSettings::Tls(tls_settings) => tls_settings.validate()?,
}
Ok(())
}
}
impl Validate for TLSSettings {
fn validate(&self) -> Result<(), ValidationError> {
match self {
TLSSettings::RawPath {
ca_path,
cert_path,
key_path,
} => {
if !std::path::Path::new(ca_path).exists() {
return Err(format!("CA file not found: {}", ca_path));
}
if !std::path::Path::new(cert_path).exists() {
return Err(format!("Certificate file not found: {}", cert_path));
}
if !std::path::Path::new(key_path).exists() {
return Err(format!("Key file not found: {}", key_path));
}
}
TLSSettings::ZipPath { cert_zip_path } => {
if !std::path::Path::new(cert_zip_path).exists() {
return Err(format!("Certificate zip file not found: {}", cert_zip_path));
}
}
}
Ok(())
}
}
impl Validate for CorsSettings {
fn validate(&self) -> Result<(), ValidationError> {
Ok(())
}
}
impl Validate for NginxSettings {
fn validate(&self) -> Result<(), ValidationError> {
match &self.nginx_binary_path {
Some(path) if path.is_empty() => {
return Err("Nginx binary path cannot be empty".into());
}
Some(path) if !std::path::Path::new(path).exists() => {
return Err(format!("Nginx binary not found: {}", path));
}
Some(path)
if !std::fs::metadata(path)
.map_err(|e| format!("Failed to read nginx binary metadata: {}", e))?
.permissions()
.mode()
& 0o111
!= 0 =>
{
return Err(format!("Nginx binary is not executable: {}", path));
}
_ => {}
}
if self.nginx_config_path.is_empty() {
return Err("Nginx config path cannot be empty".into());
}
if !std::path::Path::new(&self.nginx_config_path).exists() {
return Err(format!(
"Nginx config file not found: {}",
self.nginx_config_path
));
}
// ensure reload and test commands contain the binary path template
if !&self
.override_nginx_reload_command
.join(" ")
.contains(NGINX_BINARY_PATH_TEMPLATE)
{
return Err(format!(
"Nginx reload command must contain the binary path template '{}': {}",
NGINX_BINARY_PATH_TEMPLATE,
self.override_nginx_reload_command.join(" ")
));
}
if !&self
.override_nginx_test_command
.join(" ")
.contains(NGINX_BINARY_PATH_TEMPLATE)
{
return Err(format!(
"Nginx test command must contain the binary path template '{}': {}",
NGINX_BINARY_PATH_TEMPLATE,
self.override_nginx_test_command.join(" ")
));
}
Ok(())
}
}
fn default_log_level() -> LevelFilter {
LevelFilter::INFO
}
fn default_nginx_config_path() -> String {
"/etc/nginx/nginx.conf".into()
}
fn default_nginx_reload_command() -> Vec<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);
}
}

View File

@@ -1,166 +0,0 @@
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"));
}
}

View File

@@ -1,34 +0,0 @@
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>();
}
}

View File

@@ -1,56 +0,0 @@
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"));
}
}

View File

@@ -1,82 +0,0 @@
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);
}
}

View File

@@ -1,76 +0,0 @@
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 agent_id: String,
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>();
}
}

View File

@@ -1,280 +0,0 @@
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"));
}
}

View File

@@ -13,7 +13,7 @@ pub trait MasterConnectorTrait: Send + Sync {
&mut self,
settings: &crate::config::settings::Settings,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
fn get_client(&self) -> AgentClient;
fn get_client(&self) -> Arc<Mutex<AgentClient>>;
}
pub struct MasterConnector {
@@ -35,7 +35,7 @@ impl MasterConnectorTrait for MasterConnector {
self.connector.connect(settings).await
}
fn get_client(&self) -> AgentClient {
fn get_client(&self) -> Arc<Mutex<AgentClient>> {
self.connector.get_client()
}
}
@@ -58,7 +58,7 @@ mod tests {
struct FakeConnector {
called: Arc<AtomicBool>,
fail: bool,
client: AgentClient,
client: Arc<Mutex<AgentClient>>,
}
#[async_trait::async_trait]
@@ -74,14 +74,13 @@ mod tests {
Ok(())
}
fn get_client(&self) -> AgentClient {
fn get_client(&self) -> Arc<Mutex<AgentClient>> {
self.client.clone()
}
}
fn test_settings() -> Settings {
Settings {
agent_id: "test-agent".to_string(),
grpc: GrpcSettings {
connection_string: "https://localhost:50051".to_string(),
m_auth: MAuthSettings::Tls(TLSSettings::ZipPath {
@@ -90,14 +89,14 @@ mod tests {
cors: None,
},
log: LogSettings::default(),
nginx: Default::default(),
nginx: None,
}
}
fn test_client() -> AgentClient {
fn test_client() -> Arc<Mutex<AgentClient>> {
let channel =
tonic::transport::Channel::from_static("http://127.0.0.1:50051").connect_lazy();
AgentClient::new(channel)
Arc::new(Mutex::new(AgentClient::new(channel)))
}
#[tokio::test]
@@ -127,4 +126,18 @@ mod tests {
let result = master.connect(&test_settings()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn master_connector_returns_underlying_client() {
let shared_client = test_client();
let fake = FakeConnector {
called: Arc::new(AtomicBool::new(false)),
fail: false,
client: shared_client.clone(),
};
let master = MasterConnector::new(Box::new(fake));
let client = master.get_client();
assert!(Arc::ptr_eq(&client, &shared_client));
}
}

View File

@@ -1,6 +1,6 @@
use std::{fs::File, io::Read};
use std::{fs::File, io::Read, sync::Arc};
use tokio::fs::read;
use tokio::{fs::read, sync::Mutex};
use nxmesh_proto::agent_service_client::AgentServiceClient;
use tonic::transport::{Certificate, ClientTlsConfig, Identity};
@@ -11,7 +11,7 @@ use crate::config::settings::{MAuthSettings, TLSSettings};
use super::{AgentClient, MasterConnectorTrait};
pub struct SshMasterConnector {
client: AgentClient,
client: Arc<Mutex<AgentClient>>,
}
impl SshMasterConnector {
@@ -34,7 +34,7 @@ impl SshMasterConnector {
.connect_lazy();
// Create the gRPC client
let client = AgentServiceClient::new(endpoint);
let client = Arc::new(Mutex::new(AgentServiceClient::new(endpoint)));
Ok(Self { client })
}
@@ -126,7 +126,7 @@ impl MasterConnectorTrait for SshMasterConnector {
Ok(())
}
fn get_client(&self) -> AgentClient {
fn get_client(&self) -> Arc<Mutex<AgentClient>> {
self.client.clone()
}
}

View File

@@ -1,8 +1,7 @@
#![recursion_limit = "128"]
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use std::{process::exit, sync::Arc};
use std::process::exit;
use tracing::{error, info};
use tracing_subscriber::{
@@ -10,12 +9,11 @@ use tracing_subscriber::{
util::SubscriberInitExt,
};
use crate::service::get_services;
use crate::connector::master::{MasterConnector, MasterConnectorTrait, ssh::SshMasterConnector};
mod cli;
mod config;
mod connector;
mod service;
#[tokio::main]
async fn main() {
@@ -45,26 +43,38 @@ async fn main() {
// info!("Loaded settings: {:#?}", settings);
info!("Starting NxMesh Agent...");
let services = get_services(Arc::new(settings))
// install grpc client
#[expect(clippy::expect_used)]
let ssh_connector = SshMasterConnector::new(settings.grpc.clone())
.await
.map_err(|e| {
error!("Failed to initialize services: {}", e);
e
.inspect_err(|e| {
error!("Failed to create SSH Master Connector: {}", e);
exit(1);
})
.unwrap_or_else(|_| {
std::process::exit(1);
});
.expect("Failed to create SSH Master Connector");
let mut master_connector = MasterConnector::new(Box::new(ssh_connector));
let master_handler = services.master_handler.clone();
// spawn the long-running handler so main can wait for shutdown signal
tokio::spawn(async move {
if let Err(e) = master_handler.start_handle_master_message().await {
error!("Master message handler exited with error: {:?}", e);
if let Err(e) = master_connector.connect(&settings).await {
error!("Failed to connect to master: {}", e);
exit(1);
}
});
info!("Agent is running. Waiting for shutdown signal.");
shutdown_handler(services.master_handler.clone()).await;
// send a dummy heartbeat to verify the connection is working
let client = master_connector.get_client();
let request = nxmesh_proto::HealthReport {
..Default::default()
};
match client.lock().await.report_health(request).await {
Ok(_) => info!("Successfully sent health report to master."),
Err(e) => {
error!("Failed to send health report to master: {}", e);
exit(1);
}
}
info!("Successfully connected to master. Agent is running.");
}
fn install_tracing_subscriber()
@@ -81,20 +91,3 @@ fn install_tracing_subscriber()
reload_handle
}
async fn wait_for_shutdown_signal() {
#[expect(clippy::expect_used)]
tokio::signal::ctrl_c()
.await
.expect("Failed to listen for ctrl_c");
info!("Shutdown signal received, stopping handler.");
}
async fn shutdown_handler(master_handler: Arc<dyn service::master_handler::MasterHandler>) {
wait_for_shutdown_signal().await;
//
let _ = master_handler.stop_handle_master_message().await;
//
info!("Agent stopped.");
exit(0);
}

View File

@@ -0,0 +1,38 @@
use std::sync::Arc;
use nxmesh_proto::ConfigUpdate;
use tracing::info;
use crate::connector::master::MasterConnector;
#[async_trait::async_trait]
pub trait MasterHandler {
async fn on_config_update(
&self,
config_info: ConfigUpdate,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
}
pub struct MasterHandlerImpl {
settings: Arc<crate::config::settings::Settings>,
}
impl MasterHandlerImpl {
pub fn new(settings: impl Into<Arc<crate::config::settings::Settings>>) -> Self {
Self {
settings: settings.into(),
}
}
}
#[async_trait::async_trait]
impl MasterHandler for MasterHandlerImpl {
async fn on_config_update(
&self,
config_info: ConfigUpdate,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Received config update from master: {:?}", config_info);
Ok(())
}
}

View File

@@ -1,106 +0,0 @@
use std::sync::{Arc, Weak};
use nxmesh_proto::{
AgentMessage, ConfigUpdate, MasterMessage, command::Command, master_message::Payload,
};
use crate::service::master_handler::{MasterHandlerError, MessageResult};
#[async_trait::async_trait]
pub trait MasterMessageHandler: Send + Sync + 'static {
async fn handle_master_message(
&self,
agent_id: &str,
message: MasterMessage,
) -> MessageResult<()>;
}
#[async_trait::async_trait]
pub trait OnConfigUpdateHandler: Send + Sync + 'static {
// Handle the config update message from master, write the config content to files, validate the new config and reload nginx
async fn on_config_update(
&self,
agent_id: &str,
timestamp: i64,
message_id: &str,
config_info: ConfigUpdate,
) -> MessageResult<()>;
}
#[async_trait::async_trait]
pub trait OnCommandHandler: Send + Sync + 'static {
// Handle the command message from master, execute the command and return the result
async fn on_command(
&self,
agent_id: &str,
timestamp: i64,
message_id: &str,
command: Command,
) -> MessageResult<()>;
}
pub struct HandlerImpl<OCUH, OCH>
where
OCUH: OnConfigUpdateHandler + ?Sized,
OCH: OnCommandHandler + ?Sized,
{
on_config_update_handler: Weak<OCUH>,
on_command_handler: Weak<OCH>,
}
impl<OCUH, OCH> HandlerImpl<OCUH, OCH>
where
OCUH: OnConfigUpdateHandler + ?Sized,
OCH: OnCommandHandler + ?Sized,
{
pub fn new(on_config_update_handler: Weak<OCUH>, on_command_handler: Weak<OCH>) -> Self {
Self {
on_config_update_handler,
on_command_handler,
}
}
}
#[async_trait::async_trait]
impl<OCUH, OCH> MasterMessageHandler for HandlerImpl<OCUH, OCH>
where
OCUH: OnConfigUpdateHandler + ?Sized,
OCH: OnCommandHandler + ?Sized,
{
async fn handle_master_message(
&self,
agent_id: &str,
message: MasterMessage,
) -> MessageResult<()> {
match message.payload {
Some(Payload::ConfigUpdate(config_info)) => {
let on_config_update_handler =
self.on_config_update_handler.upgrade().ok_or_else(|| {
MasterHandlerError::MessageHandlingError(
"Failed to upgrade weak reference to config update handler".to_string(),
)
})?;
on_config_update_handler
.on_config_update(
agent_id,
message.timestamp,
&message.message_id,
config_info,
)
.await
}
Some(_) => {
// We should never receive other types of messages from the master, but we should handle it anyway
Err(MasterHandlerError::MessageHandlingError(
"Received unsupported master message type".to_string(),
))
}
None => {
// This should never happen as the master should always send a valid message, but we should handle it anyway
return Err(MasterHandlerError::MessageHandlingError(
"Received master message with empty payload".to_string(),
));
}
}
}
}

View File

@@ -1,224 +0,0 @@
use std::sync::Arc;
use nxmesh_proto::AgentMessage;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use crate::{
connector::master::{MasterConnector, MasterConnectorTrait},
service::master_handler::handlers::MasterMessageHandler,
};
pub mod handlers;
#[derive(Debug)]
pub enum MasterHandlerError {
ConnectionError(String),
// TODO: should be protobuf error to transmit the error to master
MessageHandlingError(String),
RetryLimitExceeded(String),
SendMessageError(String),
}
pub type MessageResult<T> = std::result::Result<T, MasterHandlerError>;
#[async_trait::async_trait]
pub trait MasterHandler: Send + Sync + 'static {
// Create a new routine to handle incoming messages from the master
// This method will auto-reconnect if the connection is lost, so it should run indefinitely until the agent is shut down
async fn start_handle_master_message(&self) -> MessageResult<()>;
async fn stop_handle_master_message(&self) -> MessageResult<()>;
// Send a message to the master, response should be handled by the agent message handler registered
async fn send_message_to_master(&self, message: AgentMessage) -> MessageResult<()>;
}
struct MessageHandleInfo {
tx: mpsc::Sender<AgentMessage>,
// used to signal the running handler/connection to stop
cancel: CancellationToken,
}
pub struct MasterHandlerImpl<MMH>
where
MMH: MasterMessageHandler + ?Sized,
{
agent_id: String,
connector: Arc<MasterConnector>,
message_handler: Arc<MMH>,
message_handle_lock: tokio::sync::RwLock<Option<MessageHandleInfo>>,
}
impl<MMH> MasterHandlerImpl<MMH>
where
MMH: MasterMessageHandler + ?Sized,
{
pub fn new(agent_id: &str, connector: Arc<MasterConnector>, message_handler: Arc<MMH>) -> Self {
Self {
agent_id: agent_id.to_string(),
connector,
message_handler,
message_handle_lock: tokio::sync::RwLock::new(None),
}
}
}
#[async_trait::async_trait]
impl<MMH> MasterHandler for MasterHandlerImpl<MMH>
where
MMH: MasterMessageHandler + ?Sized,
{
async fn start_handle_master_message(&self) -> MessageResult<()> {
info!("Starting master message handler...");
let mut client = self.connector.get_client();
// ensure only one caller can start the handler
// create the cancel token for the lifetime of this handler invocation
let cancel_token = CancellationToken::new();
{
let mut guard = self.message_handle_lock.write().await;
if guard.is_some() {
warn!("Master message handler is already running");
return Ok(());
}
// placeholder tx; will be replaced per-connection
let (tx, _rx) = mpsc::channel(1);
*guard = Some(MessageHandleInfo {
tx,
cancel: cancel_token.clone(),
});
}
'connection_loop: loop {
// fresh outbound channel per connection
let (tx, rx) = mpsc::channel(32);
let outbound_stream = ReceiverStream::new(rx);
// try to connect
let mut stream = match client.stream(outbound_stream).await {
Ok(s) => s.into_inner(),
Err(e) => {
error!(
"Failed to connect to master: {}. Retrying in 5 seconds...",
e
);
// update stored sender so any callers see the current tx
{
let mut guard = self.message_handle_lock.write().await;
if let Some(info) = guard.as_mut() {
info.tx = tx.clone();
}
}
let conn_token = cancel_token.child_token();
tokio::select! {
_ = conn_token.cancelled() => break 'connection_loop,
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => continue 'connection_loop,
}
}
};
// store current tx so senders can use it
{
let mut guard = self.message_handle_lock.write().await;
if let Some(info) = guard.as_mut() {
info.tx = tx.clone();
}
}
// connection-level token to observe stop requests
let conn_token = cancel_token.child_token();
info!("Connected to master, starting to receive messages...");
// process messages inline so we can clear the slot on exit
'message_processing: loop {
tokio::select! {
_ = conn_token.cancelled() => {
info!("Stop requested for master handler");
break 'connection_loop;
}
message = stream.message() => {
match message {
Ok(Some(msg)) => {
if let Err(e) = self.message_handler.handle_master_message(&self.agent_id, msg).await {
error!("Failed to handle master message: {:?}", e);
}
continue;
}
Ok(None) => {
warn!("Master closed the connection");
break 'message_processing;
}
Err(e) => {
error!("Error receiving message from master: {:?}", e);
break 'message_processing;
}
}
}
}
}
// connection ended — clear stored info
{
let mut guard = self.message_handle_lock.write().await;
guard.take();
}
// if stop requested, exit
if cancel_token.is_cancelled() {
break 'connection_loop;
}
// otherwise reconnect after backoff
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
// final cleanup
let mut guard = self.message_handle_lock.write().await;
guard.take();
Ok(())
}
async fn stop_handle_master_message(&self) -> MessageResult<()> {
// Signal the running handler to stop and wait for it to clear
let mut maybe_cancel = None;
{
let mut guard = self.message_handle_lock.write().await;
if let Some(info) = guard.take() {
maybe_cancel = Some(info.cancel);
}
}
if let Some(cancel) = maybe_cancel {
cancel.cancel();
// wait for the handler to clear (with timeout)
for _ in 0..50 {
if self.message_handle_lock.read().await.is_none() {
info!("Master message handler task stopped successfully");
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
warn!("Timed out waiting for master message handler to stop");
} else {
warn!("Master message handler is not running");
}
Ok(())
}
async fn send_message_to_master(&self, message: AgentMessage) -> MessageResult<()> {
let guard = self.message_handle_lock.read().await;
if let Some(handle_info) = guard.as_ref() {
handle_info.tx.send(message).await.map_err(|e| {
MasterHandlerError::SendMessageError(format!(
"Failed to send message to master: {}",
e
))
})?;
} else {
return Err(MasterHandlerError::SendMessageError(
"Master message handler is not running".to_string(),
));
}
Ok(())
}
}

View File

@@ -1,63 +0,0 @@
use std::sync::{Arc, Mutex};
use crate::{
config::settings::Settings,
connector::master::{MasterConnector, ssh::SshMasterConnector},
service::{
master_handler::{MasterHandler, MasterHandlerImpl, handlers::HandlerImpl},
nginx_handler::{NginxHandler, NginxHandlerImpl},
},
};
pub mod master_handler;
pub mod nginx_handler;
pub struct Services {
pub master_handler: Arc<dyn MasterHandler>,
pub nginx_handler: Arc<dyn NginxHandler>,
}
pub async fn get_services(settings: Arc<Settings>) -> anyhow::Result<Services> {
let master_connector = initialize_master_connector(settings.clone()).await?;
let master_connector = Arc::new(master_connector);
let master_handler_slot = Arc::new(Mutex::new(None));
let slot = master_handler_slot.clone();
#[expect(clippy::expect_used)]
let nginx_handler = Arc::new_cyclic(|nginx_handler_weak| {
let message_handler = Arc::new(HandlerImpl::new(
nginx_handler_weak.clone(),
nginx_handler_weak.clone(),
));
let master_handler = Arc::new(MasterHandlerImpl::new(
settings.agent_id.as_str(),
master_connector.clone(),
message_handler,
));
*slot.lock().expect("master handler slot lock poisoned") = Some(master_handler.clone());
NginxHandlerImpl::new(settings.nginx.clone().into(), master_handler)
});
#[expect(clippy::expect_used)]
let master_handler = master_handler_slot
.lock()
.expect("master handler slot lock poisoned")
.clone()
.ok_or_else(|| anyhow::anyhow!("Failed to initialize master handler"))?;
Ok(Services {
master_handler,
nginx_handler,
})
}
async fn initialize_master_connector(settings: Arc<Settings>) -> anyhow::Result<MasterConnector> {
let ssh_connector = SshMasterConnector::new(settings.grpc.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to initialize SSH connector: {}", e))?;
let master_connector = MasterConnector::new(Box::new(ssh_connector));
Ok(master_connector)
}

View File

@@ -1,252 +0,0 @@
use std::sync::Arc;
use thiserror::Error;
use tokio::process::Command;
use tracing::{debug, warn};
use crate::{config::settings::NginxSettings, service::master_handler::MasterHandlerError};
#[cfg(test)]
use mockall::predicate::*;
#[derive(Debug, Error)]
pub enum CommandHandlerError {
#[error("Failed to execute command: {0}")]
CommandExecutionError(#[from] std::io::Error),
#[error("Invalid config path: {0}")]
InvalidConfigPath(String),
#[error("Invalid output path: {0}")]
InvalidOutputPath(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Other error: {0}")]
OtherError(String),
}
impl From<CommandHandlerError> for MasterHandlerError {
fn from(err: CommandHandlerError) -> Self {
MasterHandlerError::MessageHandlingError(err.to_string())
}
}
pub type CommandHandlerResult<T> = std::result::Result<T, CommandHandlerError>;
type Result<T> = CommandHandlerResult<T>;
#[async_trait::async_trait]
#[cfg_attr(test, mockall::automock)]
pub trait CommandHandler: Send + Sync + 'static {
// Reload nginx to apply new config. The config_path is an optional parameter that specifies the path to the nginx config file to be used for this reload operation. If not provided, the default config path will be used.
async fn reload(&self, config_path: Option<&str>) -> Result<()>;
async fn stop(&self) -> Result<()>;
async fn validate(&self, config_path: Option<&str>) -> Result<()>;
async fn get_version(&self) -> Result<String>;
async fn get_status(&self) -> Result<String>;
}
pub struct CommandHandlerImpl {
settings: Arc<NginxSettings>,
}
impl CommandHandlerImpl {
pub fn new(settings: Arc<NginxSettings>) -> Self {
Self { settings }
}
fn get_nginx_command(&self) -> String {
// TODO: rename the setting for better clarity, it can be a binary path or a custom command
self.settings
.nginx_binary_path
.clone()
.unwrap_or_else(|| "nginx".to_string())
}
fn validate_config_path(config_path: &str) -> Result<()> {
if !std::path::Path::new(config_path).exists() {
return Err(CommandHandlerError::InvalidConfigPath(format!(
"Config file not found at path: {}",
config_path
)));
}
if !std::path::Path::new(config_path).is_file() {
return Err(CommandHandlerError::InvalidConfigPath(format!(
"Config path is not a file: {}",
config_path
)));
}
Ok(())
}
fn apply_config_path_to_command_vecs<'a>(
command: &'a mut Vec<String>,
config_path: &str,
) -> Result<&'a mut Vec<String>> {
// if given a config path, add it to the end of the command arguments to override the default config path used
Self::validate_config_path(config_path)?;
let parent_dir = match std::path::Path::new(config_path).parent() {
Some(dir) => dir,
// return root
None => std::path::Path::new("/"),
};
// set prefix path to the parent directory of the config file to ensure nginx can find all related files (e.g. certs, conf.d, etc.)
command.push("-p".to_string());
command.push(parent_dir.to_string_lossy().to_string());
// add the config file path to the command arguments to override the default config path used by nginx
command.push("-c".to_string());
command.push(config_path.to_string());
Ok(command)
}
}
#[async_trait::async_trait]
impl CommandHandler for CommandHandlerImpl {
async fn reload(&self, config_path: Option<&str>) -> Result<()> {
// TODO: add timeout for the command execution
let reload_command_str = self.settings.override_nginx_reload_command.clone();
let program = match reload_command_str.first() {
Some(cmd) => cmd,
None => &self.get_nginx_command(),
};
let mut reload_command_vec = reload_command_str[1..].to_vec();
// if given a config path, add it to the end of the command arguments to override the default config path used
if let Some(path) = config_path {
Self::apply_config_path_to_command_vecs(&mut reload_command_vec, path)?;
}
let output = Command::new(program)
.args(&reload_command_vec)
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
return Err(CommandHandlerError::CommandExecutionError(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to reload nginx: {}", error_info.trim()),
),
));
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx reloaded successfully: {}", success_info.trim());
Ok(())
}
async fn stop(&self) -> Result<()> {
let output = Command::new(self.get_nginx_command())
.arg("-s")
.arg("stop")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
return Err(CommandHandlerError::CommandExecutionError(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to stop nginx: {}", error_info.trim()),
),
));
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx stopped successfully: {}", success_info.trim());
Ok(())
}
async fn validate(&self, config_path: Option<&str>) -> Result<()> {
// TODO: add timeout for the command execution
let validate_command_str = self.settings.override_nginx_test_command.clone();
let program = match validate_command_str.first() {
Some(cmd) => cmd,
None => &self.get_nginx_command(),
};
let mut validate_args = validate_command_str[1..].to_vec();
// if given a config path, add it to the end of the command arguments to override the default config path used
if let Some(path) = config_path {
Self::apply_config_path_to_command_vecs(&mut validate_args, path)?;
}
let output = Command::new(program).args(&validate_args).output().await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
return Err(CommandHandlerError::CommandExecutionError(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to validate nginx config: {}", error_info.trim()),
),
));
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx config validation succeeded: {}", success_info.trim());
Ok(())
}
async fn get_version(&self) -> Result<String> {
let output = Command::new(self.get_nginx_command())
.arg("-v")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
return Err(CommandHandlerError::CommandExecutionError(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to get nginx version: {}", error_info.trim()),
),
));
}
let version_info = String::from_utf8_lossy(&output.stderr);
Ok(version_info.trim().to_string())
}
async fn get_status(&self) -> Result<String> {
let output = Command::new(self.get_nginx_command())
.arg("-t")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
return Err(CommandHandlerError::CommandExecutionError(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to get nginx status: {}", error_info.trim()),
),
));
}
let status_info = String::from_utf8_lossy(&output.stderr);
Ok(status_info.trim().to_string())
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use tempfile::TempDir;
use super::*;
#[tokio::test]
async fn apply_config_path_to_command_vecs_appends_prefix_and_config() -> Result<()> {
let temp = TempDir::new()?;
let cfg_file = temp.path().join("nginx.conf");
tokio::fs::write(&cfg_file, b"data").await?;
let mut args: Vec<String> = vec!["base".to_string()];
let result = CommandHandlerImpl::apply_config_path_to_command_vecs(
&mut args,
&cfg_file.to_string_lossy(),
);
assert!(result.is_ok());
let args = result.expect("Failed to apply config path to command vecs");
// expect -p <parent_dir> -c <config>
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"-c".to_string()));
assert!(args.contains(&cfg_file.to_string_lossy().to_string()));
Ok(())
}
}

View File

@@ -1,448 +0,0 @@
use std::sync::Arc;
use fs4::tokio::AsyncFileExt;
use thiserror::Error;
use tokio::{io::AsyncWriteExt, process::Command};
use tracing::warn;
use crate::{config::settings::NginxSettings, service::master_handler::MasterHandlerError};
#[cfg(test)]
use mockall::predicate::*;
// TODO: custom error type
#[derive(Debug, Error)]
pub enum FsHandlerError {
#[error("Invalid output path: {0}")]
InvalidOutputPath(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
impl From<FsHandlerError> for MasterHandlerError {
fn from(err: FsHandlerError) -> Self {
MasterHandlerError::MessageHandlingError(format!("File system handling error: {}", err))
}
}
pub type FsResult<T> = std::result::Result<T, FsHandlerError>;
type Result<T> = FsResult<T>;
#[async_trait::async_trait]
#[cfg_attr(test, mockall::automock)]
pub trait FsHandler: Send + Sync + 'static {
fn get_deployment_id(config_id: &str, version: &str) -> String
where
Self: Sized,
{
format!("{}-{}", config_id, version)
}
// Write a new config file for nginx.
// The output_path is a relative path to the nginx config directory of the deployment folder. The actual path to the config should not be assumed by the caller, as it can be different in different environments, but will be promised to be relative to the deployment folder for each the corresponding deployment_id. Path traversal is not allowed.
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<String>;
// Append a new config content to an existing config file for nginx. This is useful for some use cases where we want to keep the existing config and just add some new config content to it. The output_path is a relative path to the nginx config directory of the deployment folder, which should be the same as the one used in write_config function. Path traversal is not allowed.
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<String>;
// clean up old config files that are applied to nginx
// keep only latest n deployments.
async fn cleanup_config(&self, n: usize) -> Result<()>;
// Persist the root config path of the last successful deployment.
// Survives agent restarts so Reload/Test commands work without a new ConfigUpdate.
async fn save_last_deployment(&self, root_config_path: &str) -> Result<()>;
// Load the last persisted root config path, if any.
// Returns Ok(None) when no state file exists or it is empty/corrupt.
async fn load_last_deployment(&self) -> Result<Option<String>>;
}
pub struct FsHandlerImpl {
settings: Arc<NginxSettings>,
}
impl FsHandlerImpl {
pub fn new(settings: Arc<NginxSettings>) -> Self {
Self { settings }
}
fn validate_config_path(config_path: &str) -> Result<()> {
if !std::path::Path::new(config_path).exists() {
return Err(FsHandlerError::InvalidOutputPath(format!(
"Config file not found at path: {}",
config_path
)));
}
if !std::path::Path::new(config_path).is_file() {
return Err(FsHandlerError::InvalidOutputPath(format!(
"Config path is not a file: {}",
config_path
)));
}
Ok(())
}
fn get_deployment_dir(&self) -> std::path::PathBuf {
std::path::Path::new(&self.settings.nginx_config_path).join("deployments")
}
fn get_deployment_dir_path(&self, deployment_id: &str) -> std::path::PathBuf {
self.get_deployment_dir().join(deployment_id)
}
fn get_state_file_path(&self) -> std::path::PathBuf {
std::path::Path::new(&self.settings.nginx_config_path).join(".last_deployment")
}
async fn get_deployment_config_path(
&self,
deployment_id: &str,
output_path: &str,
create_dir_if_not_exists: bool,
) -> Result<std::path::PathBuf> {
let output_path_obj = std::path::Path::new(output_path);
if output_path_obj.is_absolute() {
return Err(FsHandlerError::InvalidOutputPath(
"Output path must be a relative path".into(),
));
}
if output_path_obj
.components()
.any(|comp| comp == std::path::Component::ParentDir)
{
return Err(FsHandlerError::InvalidOutputPath(
"Output path must not contain parent directory traversal".into(),
));
}
let deployment_config_dir = self.get_deployment_dir_path(deployment_id);
let full_path = deployment_config_dir.join(output_path);
if create_dir_if_not_exists {
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await?;
} else {
tokio::fs::create_dir_all(&deployment_config_dir).await?;
}
}
Ok(full_path)
}
}
#[async_trait::async_trait]
impl FsHandler for FsHandlerImpl {
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<String> {
let full_output_path = self
.get_deployment_config_path(deployment_id, output_path, true)
.await?;
let parent_dir = full_output_path.parent().ok_or_else(|| {
FsHandlerError::InvalidOutputPath(format!(
"Failed to get parent directory of output path: {:?}",
full_output_path
))
})?;
// ensure the parent directory exists before creating the file
tokio::fs::create_dir_all(parent_dir).await?;
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(full_output_path.clone())
.await?;
// lock the file for writing to prevent concurrent write issue
file.lock_exclusive()?;
file.write_all(config_content.as_bytes()).await?;
file.unlock()?;
file.flush().await?;
Ok(full_output_path.to_string_lossy().to_string())
}
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<String> {
let full_output_path = self
.get_deployment_config_path(deployment_id, output_path, true)
.await?;
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.append(true)
.open(full_output_path.clone())
.await?;
// lock the file for writing to prevent concurrent write issue
file.lock_exclusive()?;
file.write_all(config_content.as_bytes()).await?;
file.unlock()?;
file.flush().await?;
Ok(full_output_path.to_string_lossy().to_string())
}
async fn save_last_deployment(&self, root_config_path: &str) -> Result<()> {
let state_path = self.get_state_file_path();
let tmp_path = state_path.with_extension("tmp");
tokio::fs::write(&tmp_path, format!("{}\n", root_config_path)).await?;
tokio::fs::rename(&tmp_path, &state_path).await?;
Ok(())
}
async fn load_last_deployment(&self) -> Result<Option<String>> {
// primary: try state file
let state_path = self.get_state_file_path();
if state_path.exists() {
let content = tokio::fs::read_to_string(&state_path).await?;
let path = content.trim().to_string();
if !path.is_empty() {
return Ok(Some(path));
}
}
// fallback: scan deployments directory for the newest deployment
let deployment_dir = self.get_deployment_dir();
if !deployment_dir.exists() {
return Ok(None);
}
let mut entries = tokio::fs::read_dir(&deployment_dir).await?;
let mut candidates: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await.map_or(false, |t| t.is_dir()) {
if let Ok(mtime) = entry.metadata().await.and_then(|m| m.modified()) {
candidates.push((entry.path(), mtime));
}
}
}
// sort descending by mtime (newest first)
candidates.sort_by(|a, b| b.1.cmp(&a.1));
for (dir, _) in &candidates {
let mut dir_entries = tokio::fs::read_dir(dir).await?;
while let Some(file) = dir_entries.next_entry().await? {
if file.file_type().await.map_or(false, |t| t.is_file()) {
let name = file.file_name().to_string_lossy().to_string();
if name == "nginx.conf" || name.ends_with(".conf") {
let path = file.path().to_string_lossy().to_string();
return Ok(Some(path));
}
}
}
}
Ok(None)
}
async fn cleanup_config(&self, n: usize) -> Result<()> {
let deployment_dir = self.get_deployment_dir();
// loop through all files in the deployment dir and delete them
let mut entries = tokio::fs::read_dir(&deployment_dir).await?;
let mut deployment_ids = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
if file_type.is_dir()
&& let Some(deployment_id) = entry.file_name().to_str()
{
deployment_ids.push(deployment_id.to_string());
}
}
// sort the deployment ids by modified time in descending order and keep the latest n deployments, delete the rest
deployment_ids.sort_by_key(|id| {
let path = self.get_deployment_dir_path(id);
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
for deployment_id in deployment_ids.into_iter().skip(n) {
let path = self.get_deployment_dir_path(&deployment_id);
// ensure path is within the deplyment and nginx directory to prevent accidental deletion of other files
if !path.starts_with(&deployment_dir)
|| !path.starts_with(&self.settings.nginx_config_path)
{
warn!(
"Skipping deletion of path outside of deployment or nginx config directory: {:?}",
path
);
continue;
}
tokio::fs::remove_dir_all(path).await?;
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use anyhow::Result;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::test]
async fn write_and_append_config_roundtrip() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = FsHandlerImpl::new(Arc::new(settings));
handler
.write_config("deployment1", "hello", "conf/nginx.conf")
.await?;
let full_path = temp
.path()
.join("deployments")
.join("deployment1")
.join("conf/nginx.conf");
let content = tokio::fs::read_to_string(&full_path).await?;
assert_eq!(content, "hello");
handler
.append_config("deployment1", " world", "conf/nginx.conf")
.await?;
let content = tokio::fs::read_to_string(&full_path).await?;
assert_eq!(content, "hello world");
Ok(())
}
#[tokio::test]
async fn write_config_rejects_absolute_and_traversal_paths() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = FsHandlerImpl::new(Arc::new(settings));
let err = handler
.write_config("d", "x", "/absolute/path.conf")
.await
.err();
assert!(err.is_some());
let err = handler.write_config("d", "x", "../escape.conf").await.err();
assert!(err.is_some());
Ok(())
}
#[tokio::test]
async fn validate_config_path_checks_file_exists_and_is_file() {
// missing file
let res = FsHandlerImpl::validate_config_path("/this/path/does/not/exist.conf");
assert!(res.is_err());
// create a temp dir and ensure a directory is rejected
let temp = TempDir::new().expect("Failed to create temp dir");
let dir_path = temp.path();
let res = FsHandlerImpl::validate_config_path(dir_path.to_string_lossy().as_ref());
assert!(res.is_err());
}
#[tokio::test]
async fn get_deployment_config_path_create_flag_behaviour() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = FsHandlerImpl::new(Arc::new(settings));
// when create_dir_if_not_exists = false, directory shouldn't be created
let path = handler
.get_deployment_config_path("did", "conf/nginx.conf", false)
.await?;
assert!(
!path
.parent()
.expect("Failed to get parent directory of deployment config path")
.exists()
);
// when create_dir_if_not_exists = true, directory should be created
let path = handler
.get_deployment_config_path("did", "conf/nginx.conf", true)
.await?;
assert!(
path.parent()
.expect("Failed to get parent directory of deployment config path")
.exists()
);
Ok(())
}
#[tokio::test]
async fn cleanup_config_deletes_expected_deployments() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = FsHandlerImpl::new(Arc::new(settings));
let base = temp.path().join("deployments");
// create three deployments sequentially so mtimes differ
for id in &["d1", "d2", "d3"] {
let p = base.join(id);
std::fs::create_dir_all(&p)?;
std::fs::write(p.join("file"), b"x")?;
std::thread::sleep(std::time::Duration::from_millis(500));
}
// call cleanup keeping 1; current implementation keeps the oldest n, so expect only d1 remains
handler.cleanup_config(1).await?;
let mut exists = vec![];
for id in &["d1", "d2", "d3"] {
exists.push((id.to_string(), base.join(id).exists()));
}
// d1 should remain, others removed (matches current implementation behavior)
assert!(exists.iter().find(|(id, e)| id == "d1" && *e).is_some());
assert!(exists.iter().find(|(id, e)| id == "d2" && !*e).is_some());
assert!(exists.iter().find(|(id, e)| id == "d3" && !*e).is_some());
Ok(())
}
}

View File

@@ -1,185 +0,0 @@
use std::sync::Arc;
use dashmap::DashMap;
use nxmesh_proto::{
ConfigUpdate, ConfigUpdateResult,
agent_message::Payload::ConfigUpdateResult as ConfigUpdateResultPayload, command::Command,
command_result,
};
use tracing::{info, warn};
use crate::{
config::settings::NginxSettings,
service::{
master_handler::{
MasterHandler, MessageResult,
handlers::{OnCommandHandler, OnConfigUpdateHandler},
},
nginx_handler::{command_handler::CommandHandler, fs_handler::FsHandler},
},
};
const DEFAULT_CONFIG_PATH: &str = "nginx.conf";
const DEFAULT_NGINX_CONFIG_CONTENT: &str = r#"
events {}
"#;
pub trait NginxMasterMessageHandler: Send + Sync + 'static
//
+ OnConfigUpdateHandler
+ OnCommandHandler
{}
pub struct NginxMasterMessageHandlerImpl {
settings: Arc<NginxSettings>,
command_handler: Arc<dyn CommandHandler>,
fs_handler: Arc<dyn FsHandler>,
master_handler: Arc<dyn MasterHandler>,
//
// dash_map for for storing the on-going config updates, with the key as deployment_id, and the value as a tuple of (version_id, timestamp). On-going update must lock the deployment_id, and the new update with newer timestamp will wait until the lock is released. This is to ensure the config updates are applied in order.
// When the current timestamp is older than the timestamp in the map, the current update must be rejected, and the master should be informed to resend the update with the latest timestamp.
ongoing_updates: DashMap<String, (String, i64)>,
}
impl NginxMasterMessageHandlerImpl {
pub fn new(
settings: Arc<NginxSettings>,
command_handler: Arc<dyn CommandHandler>,
fs_handler: Arc<dyn FsHandler>,
master_handler: Arc<dyn MasterHandler>,
) -> Self {
Self {
settings,
command_handler,
fs_handler,
master_handler,
ongoing_updates: DashMap::new(),
}
}
}
impl NginxMasterMessageHandler for NginxMasterMessageHandlerImpl {}
#[async_trait::async_trait]
impl OnConfigUpdateHandler for NginxMasterMessageHandlerImpl {
async fn on_config_update(
&self,
agent_id: &str,
timestamp: i64,
message_id: &str,
config_info: ConfigUpdate,
) -> MessageResult<()> {
// TODO: handle concurrency, expect only the latest version with latest timestamp is applied
// when a newer config update comes in, and the older config update is still being processed. The new config will wait until the old config is applied.
let deployment_id = format!("{}-{}", config_info.config_id, config_info.version);
// write the configs
let root_config_path = match config_info.root_config {
Some(config_content) => {
self.fs_handler
.write_config(
&deployment_id,
&config_content.content,
&config_content.path,
)
.await?
}
None => {
// If the config content is not provided, write a default config to ensure the deployment folder is created and can be used for later updates.
warn!(
"Config content is not provided for config update, writing a default minimal config for deployment_id: {}",
deployment_id
);
self.fs_handler
.write_config(
&deployment_id,
DEFAULT_NGINX_CONFIG_CONTENT,
DEFAULT_CONFIG_PATH,
)
.await?
}
};
//
for config in config_info.configs {
self.fs_handler
.write_config(&deployment_id, &config.content, &config.path)
.await?;
}
// apply reload on the root config
self.command_handler.reload(Some(&root_config_path)).await?;
// persist deployment path so Reload/Test commands survive agent restarts
self.fs_handler.save_last_deployment(&root_config_path).await?;
info!("Persisted last deployment path: {}", root_config_path);
// Reply the master to confirm the config update is successful
self.master_handler
.send_message_to_master(nxmesh_proto::AgentMessage {
agent_id: agent_id.to_string(),
timestamp,
message_id: message_id.to_string(),
payload: Some(ConfigUpdateResultPayload(ConfigUpdateResult {
success: true,
error_message: None,
config_id: config_info.config_id,
version: config_info.version,
})),
})
.await?;
//
Ok(())
}
}
#[async_trait::async_trait]
impl OnCommandHandler for NginxMasterMessageHandlerImpl {
async fn on_command(
&self,
agent_id: &str,
timestamp: i64,
message_id: &str,
command: Command,
) -> MessageResult<()> {
// execute the command
let mut agent_message = nxmesh_proto::AgentMessage {
agent_id: agent_id.to_string(),
timestamp,
message_id: message_id.to_string(),
payload: None,
};
// load the last known deployment path for use with Reload/Test commands
let last_config_path = self.fs_handler.load_last_deployment().await?;
let result: command_result::Result = match command {
Command::Reload(_) => {
let result = self
.command_handler
.reload(last_config_path.as_deref())
.await;
command_result::Result::ReloadResult(nxmesh_proto::ReloadResult {
success: result.is_ok(),
error_message: result.err().map(|e| e.to_string()).unwrap_or_default(),
})
}
Command::Test(_) => {
let result = self
.command_handler
.validate(last_config_path.as_deref())
.await;
command_result::Result::TestResult(nxmesh_proto::TestResult {
success: result.is_ok(),
error_message: result.err().map(|e| e.to_string()).unwrap_or_default(),
})
}
};
// Reply the master to confirm the command execution is successful, and return the command output
agent_message.payload = Some(nxmesh_proto::agent_message::Payload::CommandResult(
nxmesh_proto::CommandResult {
result: Some(result),
},
));
self.master_handler
.send_message_to_master(agent_message)
.await?;
//
Ok(())
}
}

View File

@@ -1,161 +0,0 @@
use std::sync::Arc;
use nxmesh_proto::{ConfigUpdate, command::Command};
use crate::{
config::settings::NginxSettings,
service::{
master_handler::{
MasterHandler, MessageResult,
handlers::{OnCommandHandler, OnConfigUpdateHandler},
},
nginx_handler::{
command_handler::{CommandHandler, CommandHandlerImpl, CommandHandlerResult},
fs_handler::{FsHandler, FsHandlerImpl, FsResult},
message_handler::{NginxMasterMessageHandler, NginxMasterMessageHandlerImpl},
},
},
};
mod command_handler;
mod fs_handler;
mod message_handler;
#[cfg(test)]
use mockall::predicate::*;
// TODO: custom error type
#[async_trait::async_trait]
#[cfg_attr(test, mockall::automock)]
pub trait NginxHandler: Send + Sync + 'static {
// Reload nginx to apply new config. The config_path is an optional parameter that specifies the path to the nginx config file to be used for this reload operation. If not provided, the default config path will be used.
async fn reload(&self, config_path: Option<&str>) -> CommandHandlerResult<()>;
async fn stop(&self) -> CommandHandlerResult<()>;
async fn validate(&self, config_path: Option<&str>) -> CommandHandlerResult<()>;
async fn get_version(&self) -> CommandHandlerResult<String>;
async fn get_status(&self) -> CommandHandlerResult<String>;
// Write a new config file for nginx.
// The output_path is a relative path to the nginx config directory of the deployment folder. The actual path to the config should not be assumed by the caller, as it can be different in different environments, but will be promised to be relative to the deployment folder for each the corresponding deployment_id. Path traversal is not allowed.
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> FsResult<String>;
// Append a new config content to an existing config file for nginx. This is useful for some use cases where we want to keep the existing config and just add some new config content to it. The output_path is a relative path to the nginx config directory of the deployment folder, which should be the same as the one used in write_config function. Path traversal is not allowed.
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> FsResult<String>;
// clean up old config files that are applied to nginx
// keep only latest n deployments.
async fn cleanup_config(&self, n: usize) -> FsResult<()>;
}
pub struct NginxHandlerImpl {
settings: Arc<NginxSettings>,
command_handler: Arc<dyn CommandHandler>,
fs_handler: Arc<dyn FsHandler>,
nginx_master_message_handler: Arc<dyn NginxMasterMessageHandler>,
}
impl NginxHandlerImpl {
pub fn new(settings: Arc<NginxSettings>, master_handler: Arc<dyn MasterHandler>) -> Self {
let command_handler: Arc<dyn CommandHandler> =
Arc::new(CommandHandlerImpl::new(settings.clone()));
let fs_handler: Arc<dyn FsHandler> = Arc::new(FsHandlerImpl::new(settings.clone()));
Self {
settings: settings.clone(),
command_handler: command_handler.clone(),
fs_handler: fs_handler.clone(),
nginx_master_message_handler: Arc::new(NginxMasterMessageHandlerImpl::new(
settings.clone(),
command_handler.clone(),
fs_handler.clone(),
master_handler,
)),
}
}
}
#[async_trait::async_trait]
impl NginxHandler for NginxHandlerImpl {
async fn reload(&self, config_path: Option<&str>) -> CommandHandlerResult<()> {
self.command_handler.reload(config_path).await
}
async fn stop(&self) -> CommandHandlerResult<()> {
self.command_handler.stop().await
}
async fn validate(&self, config_path: Option<&str>) -> CommandHandlerResult<()> {
self.command_handler.validate(config_path).await
}
async fn get_version(&self) -> CommandHandlerResult<String> {
self.command_handler.get_version().await
}
async fn get_status(&self) -> CommandHandlerResult<String> {
self.command_handler.get_status().await
}
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> FsResult<String> {
self.fs_handler
.write_config(deployment_id, config_content, output_path)
.await
}
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> FsResult<String> {
self.fs_handler
.append_config(deployment_id, config_content, output_path)
.await
}
async fn cleanup_config(&self, n: usize) -> FsResult<()> {
self.fs_handler.cleanup_config(n).await
}
}
#[async_trait::async_trait]
impl OnConfigUpdateHandler for NginxHandlerImpl {
async fn on_config_update(
&self,
agent_id: &str,
timestamp: i64,
message_id: &str,
config_info: ConfigUpdate,
) -> MessageResult<()> {
self.nginx_master_message_handler
.on_config_update(agent_id, timestamp, message_id, config_info)
.await
}
}
#[async_trait::async_trait]
impl OnCommandHandler for NginxHandlerImpl {
async fn on_command(
&self,
agent_id: &str,
timestamp: i64,
message_id: &str,
command: Command,
) -> MessageResult<()> {
self.nginx_master_message_handler
.on_command(agent_id, timestamp, message_id, command)
.await
}
}

View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.react-router/

File diff suppressed because it is too large Load Diff

View File

@@ -4,18 +4,24 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "echo \"No test specified\" && exit 0"
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"@ant-design/icons": "^6.2.2",
"@react-router/node": "7.15.0",
"@react-router/serve": "7.15.0",
"antd": "^6.3.7",
"isbot": "^5.1.36",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router": "7.15.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@react-router/dev": "7.15.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -26,6 +32,6 @@
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
"vite": "^8.0.3"
}
}

View File

@@ -0,0 +1,8 @@
import type { Config } from '@react-router/dev/config';
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
appDirectory: 'src',
} satisfies Config;

View File

@@ -1,42 +1,156 @@
/* Light Professional Theme - nxMesh Dashboard */
* {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: #f5f5f5;
margin: 0;
padding: 0;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
/* Header Styles */
.ant-layout-header {
background: #ffffff !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
/* Sidebar Styles */
.ant-layout-sider {
background: #fafafa !important;
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
.ant-menu {
background: #fafafa !important;
}
.card {
padding: 2em;
.ant-menu-item,
.ant-menu-submenu-title {
color: #333 !important;
transition: all 0.3s ease;
}
.read-the-docs {
color: #888;
.ant-menu-item-selected {
background-color: #e6f7ff !important;
color: #1890ff !important;
}
.ant-menu-item:hover,
.ant-menu-submenu-title:hover {
color: #1890ff !important;
}
/* Content Area */
.ant-layout-content {
background: #f5f5f5;
}
/* Card Styles */
.ant-card {
border-color: #f0f0f0;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: all 0.3s ease;
}
.ant-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.ant-card-head {
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
/* Statistic Styles */
.ant-statistic-title {
color: #666;
font-size: 14px;
margin-bottom: 8px;
}
.ant-statistic-content {
color: #1890ff;
font-weight: 600;
font-size: 28px;
}
/* Button Styles */
.ant-btn-primary {
background-color: #1890ff;
border-color: #1890ff;
transition: all 0.3s ease;
}
.ant-btn-primary:hover {
background-color: #0050b3;
border-color: #0050b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
/* Input Styles */
.ant-input {
border-radius: 4px;
border-color: #d9d9d9;
}
.ant-input:hover {
border-color: #1890ff;
}
.ant-input-focused {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
/* Badge Styles */
.ant-badge-success {
color: #52c41a;
}
.ant-badge-processing {
color: #1890ff;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
color: #333;
font-weight: 600;
}
p {
color: #666;
line-height: 1.6;
}
/* Responsive Design */
@media (max-width: 768px) {
.ant-layout-sider {
width: 100% !important;
max-width: 100% !important;
}
.ant-layout-content {
padding: 16px !important;
}
.ant-card {
margin-bottom: 12px;
}
}

View File

@@ -1,35 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,36 @@
import type { PropsWithChildren } from 'react';
import { Layout, ConfigProvider, theme } from 'antd';
import { LayoutHeader } from './layout/Header';
import { LayoutSidebar } from './layout/Sidebar';
import { LayoutFooter } from './layout/Footer';
const { Content } = Layout;
export const RootLayout = ({ children }: PropsWithChildren) => {
return (
<ConfigProvider
theme={{
token: {
colorPrimary: '#1890ff',
borderRadius: 6,
},
algorithm: theme.defaultAlgorithm,
}}
>
<Layout style={{ minHeight: '100vh' }}>
{/* Header */}
<LayoutHeader />
<Layout>
{/* Sidebar */}
<LayoutSidebar />
{/* Main Content */}
<Content style={{ padding: '24px' }}>{children}</Content>
</Layout>
<LayoutFooter />
</Layout>
</ConfigProvider>
);
};

View File

@@ -0,0 +1,17 @@
import { Layout } from 'antd';
const { Footer } = Layout;
export const LayoutFooter = () => {
return (
<Footer
style={{
textAlign: 'center',
color: '#666',
borderTop: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
nxMesh © 2026 All Rights Reserved
</Footer>
);
};

View File

@@ -0,0 +1,47 @@
import { Space, Avatar, Input, Button, Badge, Dropdown, Layout, type DropdownProps } from 'antd';
import { UserOutlined, SearchOutlined, BellOutlined, LogoutOutlined } from '@ant-design/icons';
const { Header } = Layout;
const userMenuItems: Exclude<DropdownProps['menu'], undefined>['items'] = [
{ key: 'profile', label: 'Profile' },
{ key: 'settings', label: 'Settings' },
{ type: 'divider' },
{ key: 'logout', label: 'Logout', icon: <LogoutOutlined /> },
];
export const LayoutHeader = () => {
return (
<Header
style={{
background: '#fff',
padding: '0 24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'sticky',
top: 0,
zIndex: 999,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<h1 style={{ margin: 0, fontSize: '20px', fontWeight: 'bold', color: '#1890ff' }}>nxMesh</h1>
</div>
<Input placeholder="Search agents, workspaces..." prefix={<SearchOutlined />} style={{ width: '300px' }} />
<Space size="large">
<Badge count={3} style={{ backgroundColor: '#ff4d4f' }}>
<Button type="text" icon={<BellOutlined style={{ fontSize: '18px' }} />} />
</Badge>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']}>
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
{/* TODO: from store */}
<span>Admin User</span>
</Space>
</Dropdown>
</Space>
</Header>
);
};

View File

@@ -0,0 +1,57 @@
import { Button, Menu, Layout } from 'antd';
import { useState } from 'react';
import { DashboardOutlined, AppstoreOutlined, TeamOutlined, SettingOutlined } from '@ant-design/icons';
const { Sider } = Layout;
const menuItems = [
{ key: '1', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '2', icon: <AppstoreOutlined />, label: 'Agents' },
{ key: '3', icon: <AppstoreOutlined />, label: 'Workspaces' },
{ key: '4', icon: <TeamOutlined />, label: 'Organizations' },
{
key: '5',
icon: <SettingOutlined />,
label: 'Infrastructure',
children: [
{ key: '5-1', label: 'Virtual Hosts' },
{ key: '5-2', label: 'Upstreams' },
{ key: '5-3', label: 'Certificates' },
],
},
{ key: '6', icon: <TeamOutlined />, label: 'Users & Access' },
{ key: '7', icon: <SettingOutlined />, label: 'Settings' },
];
export const LayoutSidebar = () => {
const [collapsed, setCollapsed] = useState(false);
const [selectedKey, setSelectedKey] = useState('1');
return (
<Sider
width={250}
trigger={null}
collapsible
collapsed={collapsed}
style={{
background: '#fafafa',
borderRight: '1px solid #f0f0f0',
}}
>
<Button
type="text"
onClick={() => setCollapsed(!collapsed)}
style={{
width: '100%',
height: '64px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{collapsed ? '☰' : '☰'}
</Button>
<Menu mode="inline" selectedKeys={[selectedKey]} onSelect={(e) => setSelectedKey(e.key)} items={menuItems} style={{ border: 'none' }} />
</Sider>
);
};

View File

@@ -1,68 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,56 @@
import './App.css';
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import type { Route } from './+types/root';
import { RootLayout } from './components/Layout.tsx';
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return (
<RootLayout>
<Outlet />
</RootLayout>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@@ -0,0 +1,3 @@
import { type RouteConfig, index } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig;

View File

@@ -0,0 +1,70 @@
import type { Route } from './+types/home';
import { Row, Col, Card, Statistic, Space, Badge } from 'antd';
export function meta({}: Route.MetaArgs) {
return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }];
}
export default function Home() {
return (
<div style={{ background: '#fff', padding: '24px', borderRadius: '6px', minHeight: '400px' }}>
<h2 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: '600' }}>Dashboard</h2>
{/* Key Metrics */}
<Row gutter={[16, 16]} style={{ marginBottom: '32px' }}>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="Active Agents" value={12} prefix="📡" />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="Workspaces" value={5} prefix="📁" />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="Organizations" value={3} prefix="🏢" />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="System Health" value={98} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
</Row>
{/* Content Sections */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="Recent Activities" hoverable>
<Space direction="vertical" style={{ width: '100%' }}>
<p> Agent-01 connected successfully</p>
<p> Workspace "Production" updated</p>
<p> Certificate renewed for api.example.com</p>
<p> New user invited to organization</p>
</Space>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="System Status" hoverable>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>API Server</span>
<Badge status="success" text="Online" />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Database</span>
<Badge status="success" text="Online" />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Message Queue</span>
<Badge status="processing" text="Operational" />
</div>
</Space>
</Card>
</Col>
</Row>
</div>
);
}

View File

@@ -1,7 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite';
import { reactRouter } from '@react-router/dev/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
plugins: [reactRouter()],
ssr: {
noExternal: ['@ant-design/icons', '@rc-component/util'],
},
resolve: {
tsconfigPaths: true,
},
});

View File

@@ -0,0 +1,586 @@
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);
}
}

View File

@@ -1,58 +0,0 @@
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"));
}
}

View File

@@ -1,276 +0,0 @@
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);
}
}

View File

@@ -1,34 +0,0 @@
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>();
}
}

View File

@@ -1,48 +0,0 @@
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"));
}
}

View File

@@ -1,53 +0,0 @@
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>();
}
}

View File

@@ -1,81 +0,0 @@
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);
}
}

View File

@@ -1,75 +0,0 @@
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>();
}
}

View File

@@ -1,103 +0,0 @@
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);
}
}

View File

@@ -1,61 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "access_rule")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub server_id: Option<Uuid>,
pub location_id: Option<Uuid>,
pub r#type: String,
pub ip_cidr: String,
pub description: Option<String>,
pub priority: i32,
pub is_deleted: bool,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
#[sea_orm(
belongs_to = "super::location_block::Entity",
from = "Column::LocationId",
to = "super::location_block::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
LocationBlock,
#[sea_orm(
belongs_to = "super::server_block::Entity",
from = "Column::ServerId",
to = "super::server_block::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ServerBlock,
}
impl Related<super::location_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::LocationBlock.def()
}
}
impl Related<super::server_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServerBlock.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,64 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "agent_config_binding")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub agent_id: Option<Uuid>,
pub group_id: Option<Uuid>,
pub config_id: Uuid,
pub is_active: bool,
pub applied_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::agent_group::Entity",
from = "Column::GroupId",
to = "super::agent_group::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
AgentGroup,
#[sea_orm(
belongs_to = "super::agents::Entity",
from = "Column::AgentId",
to = "super::agents::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Agents,
#[sea_orm(
belongs_to = "super::proxy_config::Entity",
from = "Column::ConfigId",
to = "super::proxy_config::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ProxyConfig,
}
impl Related<super::agent_group::Entity> for Entity {
fn to() -> RelationDef {
Relation::AgentGroup.def()
}
}
impl Related<super::agents::Entity> for Entity {
fn to() -> RelationDef {
Relation::Agents.def()
}
}
impl Related<super::proxy_config::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProxyConfig.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,35 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "agent_group")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub description: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::agent_config_binding::Entity")]
AgentConfigBinding,
#[sea_orm(has_many = "super::agents::Entity")]
Agents,
}
impl Related<super::agent_config_binding::Entity> for Entity {
fn to() -> RelationDef {
Relation::AgentConfigBinding.def()
}
}
impl Related<super::agents::Entity> for Entity {
fn to() -> RelationDef {
Relation::Agents.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -19,33 +19,9 @@ pub struct Model {
pub labels: Option<Json>,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub group_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::agent_config_binding::Entity")]
AgentConfigBinding,
#[sea_orm(
belongs_to = "super::agent_group::Entity",
from = "Column::GroupId",
to = "super::agent_group::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
AgentGroup,
}
impl Related<super::agent_config_binding::Entity> for Entity {
fn to() -> RelationDef {
Relation::AgentConfigBinding.def()
}
}
impl Related<super::agent_group::Entity> for Entity {
fn to() -> RelationDef {
Relation::AgentGroup.def()
}
}
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,37 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "cache_zone")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub path: String,
pub size_limit: String,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
#[sea_orm(has_many = "super::proxy_setting::Entity")]
ProxySetting,
}
impl Related<super::proxy_setting::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProxySetting.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,37 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "config_inheritance")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub child_config_id: Uuid,
pub parent_config_id: Uuid,
pub priority: Option<i32>,
pub applied_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::proxy_config::Entity",
from = "Column::ChildConfigId",
to = "super::proxy_config::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ProxyConfig2,
#[sea_orm(
belongs_to = "super::proxy_config::Entity",
from = "Column::ParentConfigId",
to = "super::proxy_config::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ProxyConfig1,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,59 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "limit_rule")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub location_id: Uuid,
pub zone_id: Uuid,
pub burst: Option<i32>,
pub nodelay: Option<bool>,
pub is_deleted: bool,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
#[sea_orm(
belongs_to = "super::limit_zone::Entity",
from = "Column::ZoneId",
to = "super::limit_zone::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
LimitZone,
#[sea_orm(
belongs_to = "super::location_block::Entity",
from = "Column::LocationId",
to = "super::location_block::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
LocationBlock,
}
impl Related<super::limit_zone::Entity> for Entity {
fn to() -> RelationDef {
Relation::LimitZone.def()
}
}
impl Related<super::location_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::LocationBlock.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,37 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "limit_zone")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub key: String,
pub rate: String,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::limit_rule::Entity")]
LimitRule,
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
}
impl Related<super::limit_rule::Entity> for Entity {
fn to() -> RelationDef {
Relation::LimitRule.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,91 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "location_block")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub server_id: Uuid,
pub path_pattern: String,
pub proxy_pass_upstream_id: Option<Uuid>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub metadata: Option<Json>,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::access_rule::Entity")]
AccessRule,
#[sea_orm(has_many = "super::limit_rule::Entity")]
LimitRule,
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
#[sea_orm(has_many = "super::proxy_setting::Entity")]
ProxySetting,
#[sea_orm(has_many = "super::rewrite_rule::Entity")]
RewriteRule,
#[sea_orm(
belongs_to = "super::server_block::Entity",
from = "Column::ServerId",
to = "super::server_block::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ServerBlock,
#[sea_orm(
belongs_to = "super::upstream::Entity",
from = "Column::ProxyPassUpstreamId",
to = "super::upstream::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
Upstream,
}
impl Related<super::access_rule::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccessRule.def()
}
}
impl Related<super::limit_rule::Entity> for Entity {
fn to() -> RelationDef {
Relation::LimitRule.def()
}
}
impl Related<super::proxy_setting::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProxySetting.def()
}
}
impl Related<super::rewrite_rule::Entity> for Entity {
fn to() -> RelationDef {
Relation::RewriteRule.def()
}
}
impl Related<super::server_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServerBlock.def()
}
}
impl Related<super::upstream::Entity> for Entity {
fn to() -> RelationDef {
Relation::Upstream.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,44 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "log_setting")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub server_id: Uuid,
pub access_log_path: Option<String>,
pub error_log_path: Option<String>,
pub log_level: Option<String>,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
#[sea_orm(
belongs_to = "super::server_block::Entity",
from = "Column::ServerId",
to = "super::server_block::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
ServerBlock,
}
impl Related<super::server_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServerBlock.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -2,20 +2,5 @@
pub mod prelude;
pub mod access_rule;
pub mod agent_config_binding;
pub mod agent_group;
pub mod agents;
pub mod cache_zone;
pub mod config_inheritance;
pub mod limit_rule;
pub mod limit_zone;
pub mod location_block;
pub mod log_setting;
pub mod proxy_config;
pub mod proxy_setting;
pub mod public_key_revocations;
pub mod rewrite_rule;
pub mod server_block;
pub mod ssl_certificate;
pub mod upstream;

View File

@@ -1,19 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
pub use super::access_rule::Entity as AccessRule;
pub use super::agent_config_binding::Entity as AgentConfigBinding;
pub use super::agent_group::Entity as AgentGroup;
pub use super::agents::Entity as Agents;
pub use super::cache_zone::Entity as CacheZone;
pub use super::config_inheritance::Entity as ConfigInheritance;
pub use super::limit_rule::Entity as LimitRule;
pub use super::limit_zone::Entity as LimitZone;
pub use super::location_block::Entity as LocationBlock;
pub use super::log_setting::Entity as LogSetting;
pub use super::proxy_config::Entity as ProxyConfig;
pub use super::proxy_setting::Entity as ProxySetting;
pub use super::public_key_revocations::Entity as PublicKeyRevocations;
pub use super::rewrite_rule::Entity as RewriteRule;
pub use super::server_block::Entity as ServerBlock;
pub use super::ssl_certificate::Entity as SslCertificate;
pub use super::upstream::Entity as Upstream;

View File

@@ -1,46 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "proxy_config")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub is_template: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::agent_config_binding::Entity")]
AgentConfigBinding,
#[sea_orm(has_many = "super::server_block::Entity")]
ServerBlock,
#[sea_orm(has_many = "super::upstream::Entity")]
Upstream,
}
impl Related<super::agent_config_binding::Entity> for Entity {
fn to() -> RelationDef {
Relation::AgentConfigBinding.def()
}
}
impl Related<super::server_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServerBlock.def()
}
}
impl Related<super::upstream::Entity> for Entity {
fn to() -> RelationDef {
Relation::Upstream.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,60 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "proxy_setting")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub location_id: Uuid,
pub read_timeout: Option<i32>,
pub connect_timeout: Option<i32>,
pub buffer_size: Option<i32>,
pub cache_enabled: Option<bool>,
pub cache_zone: Option<Uuid>,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::cache_zone::Entity",
from = "Column::CacheZone",
to = "super::cache_zone::Column::Id",
on_update = "NoAction",
on_delete = "SetNull"
)]
CacheZone,
#[sea_orm(
belongs_to = "super::location_block::Entity",
from = "Column::LocationId",
to = "super::location_block::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
LocationBlock,
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
}
impl Related<super::cache_zone::Entity> for Entity {
fn to() -> RelationDef {
Relation::CacheZone.def()
}
}
impl Related<super::location_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::LocationBlock.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,46 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "rewrite_rule")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub location_id: Uuid,
pub pattern: String,
pub replacement: String,
pub flag: Option<String>,
pub priority: i32,
pub is_deleted: bool,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::location_block::Entity",
from = "Column::LocationId",
to = "super::location_block::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
LocationBlock,
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
}
impl Related<super::location_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::LocationBlock.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,83 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "server_block")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub config_id: Uuid,
pub server_name: Option<Vec<String>>,
pub listen_port: i32,
pub ssl_enabled: Option<bool>,
pub ssl_cert_id: Option<Uuid>,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::access_rule::Entity")]
AccessRule,
#[sea_orm(has_many = "super::location_block::Entity")]
LocationBlock,
#[sea_orm(has_many = "super::log_setting::Entity")]
LogSetting,
#[sea_orm(
belongs_to = "super::proxy_config::Entity",
from = "Column::ConfigId",
to = "super::proxy_config::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ProxyConfig,
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
#[sea_orm(
belongs_to = "super::ssl_certificate::Entity",
from = "Column::SslCertId",
to = "super::ssl_certificate::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SslCertificate,
}
impl Related<super::access_rule::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccessRule.def()
}
}
impl Related<super::location_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::LocationBlock.def()
}
}
impl Related<super::log_setting::Entity> for Entity {
fn to() -> RelationDef {
Relation::LogSetting.def()
}
}
impl Related<super::proxy_config::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProxyConfig.def()
}
}
impl Related<super::ssl_certificate::Entity> for Entity {
fn to() -> RelationDef {
Relation::SslCertificate.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,29 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ssl_certificate")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub cert_path: String,
pub key_path: String,
pub expiry_date: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::server_block::Entity")]
ServerBlock,
}
impl Related<super::server_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServerBlock.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,54 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "upstream")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub config_id: Uuid,
pub name: String,
pub target_host: String,
pub target_port: i32,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub metadata: Option<Json>,
pub override_of_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::location_block::Entity")]
LocationBlock,
#[sea_orm(
belongs_to = "super::proxy_config::Entity",
from = "Column::ConfigId",
to = "super::proxy_config::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ProxyConfig,
#[sea_orm(
belongs_to = "Entity",
from = "Column::OverrideOfId",
to = "Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
SelfRef,
}
impl Related<super::location_block::Entity> for Entity {
fn to() -> RelationDef {
Relation::LocationBlock.def()
}
}
impl Related<super::proxy_config::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProxyConfig.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -37,11 +37,7 @@ pub async fn get_fallback_handler() -> Result<axum::response::Html<Vec<u8>>, axu
}
fn get_index_html() -> Option<Vec<u8>> {
// 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())
FrontendAssets::get(INDEX_HTML).map(|asset| asset.data.as_ref().to_owned())
}
async fn get_file_handler(
@@ -53,10 +49,7 @@ async fn get_file_handler(
path
};
// Try direct lookup first, then fallback to the `client/` subfolder.
match FrontendAssets::get(&file_path)
.or_else(|| FrontendAssets::get(&format!("client/{}", file_path)))
{
match FrontendAssets::get(&file_path) {
Some(asset) => {
let content_type = mime_guess::from_path(&file_path).first_or_octet_stream();
let response = axum::response::Response::builder()

View File

@@ -1,6 +1,8 @@
use nxmesh_proto::{AgentMessage, MasterMessage, agent_service_server::AgentService};
pub mod repo;
use nxmesh_proto::{
Ack, AgentMessage, HealthReport, MasterMessage, MetricsBatch,
agent_service_server::AgentService,
};
use tracing::warn;
#[derive(Debug, Default)]
pub struct AgentServerService {}
@@ -23,13 +25,30 @@ impl AgentService for AgentServerService {
todo!()
}
async fn connection_test(
#[doc = " ReportHealth sends a health report to the master"]
#[allow(
mismatched_lifetime_syntaxes,
clippy::type_complexity,
clippy::type_repetition_in_bounds
)]
async fn report_health(
&self,
_request: tonic::Request<nxmesh_proto::TestRequest>,
) -> Result<tonic::Response<nxmesh_proto::TestResponse>, tonic::Status> {
Ok(tonic::Response::new(nxmesh_proto::TestResponse {
success: true,
error_message: String::new(),
}))
request: tonic::Request<HealthReport>,
) -> Result<tonic::Response<Ack>, tonic::Status> {
warn!("Received health report: {:?}", request.get_ref());
todo!()
}
#[doc = " ReportMetrics sends metrics batch to the master"]
#[allow(
mismatched_lifetime_syntaxes,
clippy::type_complexity,
clippy::type_repetition_in_bounds
)]
async fn report_metrics(
&self,
request: tonic::Request<MetricsBatch>,
) -> Result<tonic::Response<Ack>, tonic::Status> {
todo!()
}
}

View File

@@ -6,7 +6,6 @@ use crate::{connector::agent::AgentConnectorTrait, service::certificate::Certifi
pub mod agent;
pub mod certificate;
pub mod proxy;
pub async fn start_master_server(
settings: crate::config::settings::Settings,

View File

@@ -1,12 +0,0 @@
use crate::service::proxy::types::{ProxyConfig, ProxyServiceResult};
pub(crate) mod nginx;
pub(crate) mod repo;
pub mod service;
pub mod types;
#[async_trait::async_trait]
pub trait ProxyServiceTrait: Send + Sync + 'static {
async fn get_proxy_config(&self, proxy_id: uuid::Uuid) -> ProxyServiceResult<ProxyConfig>;
}

View File

@@ -1,7 +0,0 @@
use crate::db::entities::access_rule::Model as AccessRule;
impl std::fmt::Display for AccessRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {};", self.r#type.clone(), self.ip_cidr.clone())
}
}

View File

@@ -1,11 +0,0 @@
use crate::db::entities::cache_zone::Model as CacheZone;
impl std::fmt::Display for CacheZone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"proxy_cache_path {} levels=1:2 keys_zone={}:{};",
self.path, self.name, self.size_limit
)
}
}

View File

@@ -1,19 +0,0 @@
use crate::db::entities::limit_rule::Model as LimitRule;
pub struct LimitRuleRender {
pub rule: LimitRule,
pub zone_name: String,
}
impl std::fmt::Display for LimitRuleRender {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "limit_req zone={}", self.zone_name)?;
if let Some(burst) = self.rule.burst {
write!(f, " burst={}", burst)?;
}
if self.rule.nodelay.unwrap_or(false) {
write!(f, " nodelay")?;
}
write!(f, ";")
}
}

View File

@@ -1,11 +0,0 @@
use crate::db::entities::limit_zone::Model as LimitZone;
impl std::fmt::Display for LimitZone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"limit_req_zone {} zone={}:{};",
self.key, self.name, self.rate
)
}
}

View File

@@ -1,44 +0,0 @@
use crate::db::entities::location_block::Model as LocationBlock;
pub struct LocationBlockRender {
pub block: LocationBlock,
pub upstream_name: Option<String>,
pub access_rules: Vec<String>,
pub rewrite_rules: Vec<String>,
pub proxy_setting: Option<String>,
pub limit_rules: Vec<String>,
}
impl std::fmt::Display for LocationBlockRender {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, " location {} {{", self.block.path_pattern)?;
if let Some(ref upstream) = self.upstream_name {
writeln!(f, " proxy_pass http://{};", upstream)?;
writeln!(f, " proxy_set_header Host $host;")?;
writeln!(f, " proxy_set_header X-Real-IP $remote_addr;")?;
}
if let Some(ref settings) = self.proxy_setting {
for line in settings.lines() {
if !line.is_empty() {
writeln!(f, "{}", line)?;
}
}
}
for rule in &self.access_rules {
writeln!(f, " {}", rule)?;
}
for rule in &self.rewrite_rules {
writeln!(f, " {}", rule)?;
}
for rule in &self.limit_rules {
writeln!(f, " {}", rule)?;
}
writeln!(f, " }}")
}
}

View File

@@ -1,21 +0,0 @@
use crate::db::entities::log_setting::Model as LogSetting;
impl std::fmt::Display for LogSetting {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ref path) = self.access_log_path {
if let Some(ref level) = self.log_level {
writeln!(f, " access_log {} {};", path, level)?;
} else {
writeln!(f, " access_log {};", path)?;
}
}
if let Some(ref path) = self.error_log_path {
if let Some(ref level) = self.log_level {
writeln!(f, " error_log {} {};", path, level)?;
} else {
writeln!(f, " error_log {};", path)?;
}
}
Ok(())
}
}

View File

@@ -1,11 +0,0 @@
mod access_rule;
mod cache_zone;
mod limit_rule;
mod limit_zone;
mod location_block;
mod log_setting;
mod proxy_setting;
mod rewrite_rule;
mod server_block;
mod ssl_certificate;
mod upstream;

View File

@@ -1,35 +0,0 @@
use crate::db::entities::proxy_setting::Model as ProxySetting;
pub struct ProxySettingRender {
pub setting: ProxySetting,
pub cache_zone_name: Option<String>,
}
impl ProxySettingRender {
fn render_timeout(value: Option<i32>, directive: &str) -> Option<String> {
value.map(|v| format!(" {} {}s;", directive, v))
}
}
impl std::fmt::Display for ProxySettingRender {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(line) = Self::render_timeout(self.setting.read_timeout, "proxy_read_timeout")
{
writeln!(f, "{}", line)?;
}
if let Some(line) =
Self::render_timeout(self.setting.connect_timeout, "proxy_connect_timeout")
{
writeln!(f, "{}", line)?;
}
if let Some(buffer) = self.setting.buffer_size {
writeln!(f, " proxy_buffer_size {};", buffer)?;
}
if self.setting.cache_enabled.unwrap_or(false) {
if let Some(ref zone_name) = self.cache_zone_name {
writeln!(f, " proxy_cache {};", zone_name)?;
}
}
Ok(())
}
}

View File

@@ -1,11 +0,0 @@
use crate::db::entities::rewrite_rule::Model as RewriteRule;
impl std::fmt::Display for RewriteRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "rewrite {} {}", self.pattern, self.replacement)?;
if let Some(ref flag) = self.flag {
write!(f, " {}", flag)?;
}
write!(f, ";")
}
}

View File

@@ -1,49 +0,0 @@
use crate::db::entities::server_block::Model as ServerBlock;
pub struct ServerBlockRender {
pub block: ServerBlock,
pub ssl_cert: Option<String>,
pub locations: Vec<String>,
pub access_rules: Vec<String>,
pub log_setting: Option<String>,
}
impl std::fmt::Display for ServerBlockRender {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "server {{")?;
if self.block.ssl_enabled.unwrap_or(false) {
writeln!(f, " listen {} ssl;", self.block.listen_port)?;
} else {
writeln!(f, " listen {};", self.block.listen_port)?;
}
if let Some(ref names) = self.block.server_name {
if !names.is_empty() {
writeln!(f, " server_name {};", names.join(" "))?;
}
}
if let Some(ref cert) = self.ssl_cert {
writeln!(f, "{}", cert)?;
}
if let Some(ref log) = self.log_setting {
for line in log.lines() {
if !line.is_empty() {
writeln!(f, "{}", line)?;
}
}
}
for rule in &self.access_rules {
writeln!(f, " {}", rule)?;
}
for loc in &self.locations {
writeln!(f, "{}", loc)?;
}
write!(f, "}}")
}
}

View File

@@ -1,8 +0,0 @@
use crate::db::entities::ssl_certificate::Model as SslCertificate;
impl std::fmt::Display for SslCertificate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, " ssl_certificate {};", self.cert_path)?;
writeln!(f, " ssl_certificate_key {};", self.key_path)
}
}

View File

@@ -1,7 +0,0 @@
use crate::db::entities::upstream::Model as Upstream;
impl std::fmt::Display for Upstream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, " server {}:{};", self.target_host, self.target_port)
}
}

View File

@@ -1 +0,0 @@
pub mod config;

View File

@@ -1,444 +0,0 @@
use std::collections::HashMap;
use sea_orm::{DatabaseConnection, prelude::*};
use crate::service::proxy::types::{
Mergeable, OverrideRef, ProxyConfig, ProxyServiceError, ProxyServiceResult, ProxyType,
};
#[async_trait::async_trait]
pub trait ProxyRepo: Send + Sync + 'static {
// get the raw config for the given proxy_id. This should return the config of the given proxy_id without merging it with its parent configs (if any).
async fn get_proxy_raw_config(&self, proxy_id: uuid::Uuid) -> ProxyServiceResult<ProxyConfig>;
// get the raw config for the given proxy_id. This should return the config of the given proxy_id and all its parent configs (if any) without merging them. The returned vector is ordered from the leaf (given proxy_id) down to the root config (most specific to least specific).
async fn get_proxy_raw_configs(
&self,
proxy_id: uuid::Uuid,
) -> ProxyServiceResult<Vec<ProxyConfig>>;
// get the merged config for the given proxy_id. This should merge the config of the given proxy_id with its parent configs (if any) and return the final merged config.
async fn get_merged_proxy_config(
&self,
proxy_id: uuid::Uuid,
) -> ProxyServiceResult<ProxyConfig>;
}
pub(crate) struct ProxyRepoImpl {
db: DatabaseConnection,
}
impl ProxyRepoImpl {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
}
#[async_trait::async_trait]
impl ProxyRepo for ProxyRepoImpl {
async fn get_proxy_raw_configs(
&self,
proxy_config_id: uuid::Uuid,
) -> ProxyServiceResult<Vec<ProxyConfig>> {
let mut proxy_config_id_frontier = vec![proxy_config_id];
let mut visited = std::collections::HashSet::new();
let mut configs: Vec<ProxyConfig> = Vec::new();
while let Some(current_id) = proxy_config_id_frontier.pop() {
if visited.contains(&current_id) {
continue;
}
visited.insert(current_id);
//
let config = self.get_proxy_raw_config(current_id).await?;
//
if let Some(parent_ids) = &config.parent_config_id {
proxy_config_id_frontier.extend(parent_ids);
}
configs.push(config);
}
Ok(configs)
}
async fn get_proxy_raw_config(&self, proxy_id: uuid::Uuid) -> ProxyServiceResult<ProxyConfig> {
let proxy_entity = crate::db::entities::proxy_config::Entity::find_by_id(proxy_id)
.one(&self.db)
.await?
.ok_or(ProxyServiceError::ConfigNotFound)?;
// Resolve parent configs from config_inheritance
let parent_config_id = {
let inheritance = crate::db::entities::config_inheritance::Entity::find()
.filter(crate::db::entities::config_inheritance::Column::ChildConfigId.eq(proxy_id))
.all(&self.db)
.await?;
if inheritance.is_empty() {
None
} else {
Some(
inheritance
.into_iter()
.map(|ci| ci.parent_config_id)
.collect(),
)
}
};
// Direct children of proxy_config
let server_blocks = crate::db::entities::server_block::Entity::find()
.filter(crate::db::entities::server_block::Column::ConfigId.eq(proxy_id))
.all(&self.db)
.await?;
let upstreams = crate::db::entities::upstream::Entity::find()
.filter(crate::db::entities::upstream::Column::ConfigId.eq(proxy_id))
.all(&self.db)
.await?;
// Children of server_blocks
let server_block_ids: Vec<uuid::Uuid> = server_blocks.iter().map(|sb| sb.id).collect();
let (location_blocks, log_settings, server_access_rules) = if server_block_ids.is_empty() {
(vec![], vec![], vec![])
} else {
(
crate::db::entities::location_block::Entity::find()
.filter(
crate::db::entities::location_block::Column::ServerId
.is_in(server_block_ids.clone()),
)
.all(&self.db)
.await?,
crate::db::entities::log_setting::Entity::find()
.filter(
crate::db::entities::log_setting::Column::ServerId
.is_in(server_block_ids.clone()),
)
.all(&self.db)
.await?,
crate::db::entities::access_rule::Entity::find()
.filter(
crate::db::entities::access_rule::Column::ServerId.is_in(server_block_ids),
)
.all(&self.db)
.await?,
)
};
// Children of location_blocks
let location_block_ids: Vec<uuid::Uuid> = location_blocks.iter().map(|lb| lb.id).collect();
let (location_access_rules, limit_rules, proxy_settings, rewrite_rules) =
if location_block_ids.is_empty() {
(vec![], vec![], vec![], vec![])
} else {
(
crate::db::entities::access_rule::Entity::find()
.filter(
crate::db::entities::access_rule::Column::LocationId
.is_in(location_block_ids.clone()),
)
.all(&self.db)
.await?,
crate::db::entities::limit_rule::Entity::find()
.filter(
crate::db::entities::limit_rule::Column::LocationId
.is_in(location_block_ids.clone()),
)
.all(&self.db)
.await?,
crate::db::entities::proxy_setting::Entity::find()
.filter(
crate::db::entities::proxy_setting::Column::LocationId
.is_in(location_block_ids.clone()),
)
.all(&self.db)
.await?,
crate::db::entities::rewrite_rule::Entity::find()
.filter(
crate::db::entities::rewrite_rule::Column::LocationId
.is_in(location_block_ids),
)
.all(&self.db)
.await?,
)
};
// Collect referenced IDs for zone and cert lookups
let limit_zone_ids: Vec<uuid::Uuid> = limit_rules.iter().map(|lr| lr.zone_id).collect();
let cache_zone_ids: Vec<uuid::Uuid> = proxy_settings
.iter()
.filter_map(|ps| ps.cache_zone)
.collect();
let ssl_cert_ids: Vec<uuid::Uuid> = server_blocks
.iter()
.filter_map(|sb| sb.ssl_cert_id)
.collect();
let limit_zones = if limit_zone_ids.is_empty() {
vec![]
} else {
crate::db::entities::limit_zone::Entity::find()
.filter(crate::db::entities::limit_zone::Column::Id.is_in(limit_zone_ids))
.all(&self.db)
.await?
};
let cache_zones = if cache_zone_ids.is_empty() {
vec![]
} else {
crate::db::entities::cache_zone::Entity::find()
.filter(crate::db::entities::cache_zone::Column::Id.is_in(cache_zone_ids))
.all(&self.db)
.await?
};
let ssl_certificates = if ssl_cert_ids.is_empty() {
vec![]
} else {
crate::db::entities::ssl_certificate::Entity::find()
.filter(crate::db::entities::ssl_certificate::Column::Id.is_in(ssl_cert_ids))
.all(&self.db)
.await?
};
// ── Group child IDs by parent ────────────────────────────────────────
// location_block IDs grouped by server_id
let loc_block_ids_by_server: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for lb in &location_blocks {
map.entry(lb.server_id).or_default().push(OverrideRef {
id: lb.id,
override_of_id: lb.override_of_id,
});
}
map
};
// log_setting IDs grouped by server_id
let log_setting_ids_by_server: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for ls in &log_settings {
map.entry(ls.server_id).or_default().push(OverrideRef {
id: ls.id,
override_of_id: ls.override_of_id,
});
}
map
};
// server-level access_rule IDs grouped by server_id
let server_ar_ids_by_server: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for ar in &server_access_rules {
if let Some(sid) = ar.server_id {
map.entry(sid).or_default().push(OverrideRef {
id: ar.id,
override_of_id: ar.override_of_id,
});
}
}
map
};
// location-level access_rule IDs grouped by location_id
let loc_ar_ids_by_location: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for ar in &location_access_rules {
if let Some(lid) = ar.location_id {
map.entry(lid).or_default().push(OverrideRef {
id: ar.id,
override_of_id: ar.override_of_id,
});
}
}
map
};
// limit_rule IDs grouped by location_id
let lr_ids_by_location: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for lr in &limit_rules {
map.entry(lr.location_id).or_default().push(OverrideRef {
id: lr.id,
override_of_id: lr.override_of_id,
});
}
map
};
// proxy_setting IDs grouped by location_id
let ps_ids_by_location: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for ps in &proxy_settings {
map.entry(ps.location_id).or_default().push(OverrideRef {
id: ps.id,
override_of_id: ps.override_of_id,
});
}
map
};
// rewrite_rule IDs grouped by location_id
let rr_ids_by_location: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for rr in &rewrite_rules {
map.entry(rr.location_id).or_default().push(OverrideRef {
id: rr.id,
override_of_id: rr.override_of_id,
});
}
map
};
// location_block IDs grouped by proxy_pass_upstream_id (reverse FK)
let loc_block_ids_by_upstream: HashMap<uuid::Uuid, Vec<OverrideRef>> = {
let mut map: HashMap<_, Vec<_>> = HashMap::new();
for lb in &location_blocks {
if let Some(up_id) = lb.proxy_pass_upstream_id {
map.entry(up_id).or_default().push(OverrideRef {
id: lb.id,
override_of_id: lb.override_of_id,
});
}
}
map
};
// ── Build child-ID tuples for each config type ───────────────────────
let location_block_tuples: Vec<(
crate::db::entities::location_block::Model,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
)> = location_blocks
.into_iter()
.map(|lb| {
let ar_ids = loc_ar_ids_by_location
.get(&lb.id)
.cloned()
.unwrap_or_default();
let lr_ids = lr_ids_by_location.get(&lb.id).cloned().unwrap_or_default();
let ps_ids = ps_ids_by_location.get(&lb.id).cloned().unwrap_or_default();
let rr_ids = rr_ids_by_location.get(&lb.id).cloned().unwrap_or_default();
(lb, ar_ids, lr_ids, ps_ids, rr_ids)
})
.collect();
let server_block_tuples: Vec<(
crate::db::entities::server_block::Model,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
)> = server_blocks
.into_iter()
.map(|sb| {
let ar_ids = server_ar_ids_by_server
.get(&sb.id)
.cloned()
.unwrap_or_default();
let lb_ids = loc_block_ids_by_server
.get(&sb.id)
.cloned()
.unwrap_or_default();
let ls_ids = log_setting_ids_by_server
.get(&sb.id)
.cloned()
.unwrap_or_default();
let sc_ids: Vec<OverrideRef> = sb
.ssl_cert_id
.into_iter()
.map(|id| OverrideRef {
id,
override_of_id: None,
})
.collect();
(sb, ar_ids, lb_ids, ls_ids, sc_ids)
})
.collect();
let upstream_tuples: Vec<(crate::db::entities::upstream::Model, Vec<OverrideRef>)> =
upstreams
.into_iter()
.map(|u| {
let lb_ids = loc_block_ids_by_upstream
.get(&u.id)
.cloned()
.unwrap_or_default();
(u, lb_ids)
})
.collect();
// Combine server-level and location-level access rules into one flat list
let all_access_rules: Vec<crate::db::entities::access_rule::Model> = {
let mut ars =
Vec::with_capacity(server_access_rules.len() + location_access_rules.len());
ars.extend(server_access_rules);
ars.extend(location_access_rules);
ars
};
Ok(ProxyConfig {
id: proxy_entity.id,
name: proxy_entity.name,
r#type: ProxyType::Nginx,
description: proxy_entity.description,
parent_config_id,
server_blocks: server_block_tuples
.into_iter()
.map(|m| (m.0.id, m.into()))
.collect(),
upstreams: upstream_tuples
.into_iter()
.map(|m| (m.0.id, m.into()))
.collect(),
location_blocks: location_block_tuples
.into_iter()
.map(|m| (m.0.id, m.into()))
.collect(),
access_rules: all_access_rules
.into_iter()
.map(|m| (m.id, m.into()))
.collect(),
cache_zones: cache_zones.into_iter().map(|m| (m.id, m.into())).collect(),
limit_rules: limit_rules.into_iter().map(|m| (m.id, m.into())).collect(),
limit_zones: limit_zones.into_iter().map(|m| (m.id, m.into())).collect(),
log_settings: log_settings.into_iter().map(|m| (m.id, m.into())).collect(),
proxy_settings: proxy_settings
.into_iter()
.map(|m| (m.id, m.into()))
.collect(),
rewrite_rules: rewrite_rules
.into_iter()
.map(|m| (m.id, m.into()))
.collect(),
ssl_certificates: ssl_certificates
.into_iter()
.map(|m| (m.id, m.into()))
.collect(),
})
}
async fn get_merged_proxy_config(
&self,
proxy_id: uuid::Uuid,
) -> ProxyServiceResult<ProxyConfig> {
let configs = self.get_proxy_raw_configs(proxy_id).await?;
// configs is ordered [leaf, ..., root] (most specific first)
// self.merge(other) means self overrides other
// So start with leaf and merge each ancestor into it
let mut iter = configs.into_iter();
let mut merged = iter.next().ok_or(ProxyServiceError::ConfigNotFound)?;
for config in iter {
merged.merge(config);
}
Ok(merged)
}
}

View File

@@ -1,478 +0,0 @@
use std::collections::HashMap;
#[derive(Debug)]
pub enum ProxyServiceError {
ConfigNotFound,
InvalidConfig,
DatabaseError(sea_orm::DbErr),
}
impl From<sea_orm::DbErr> for ProxyServiceError {
fn from(err: sea_orm::DbErr) -> Self {
ProxyServiceError::DatabaseError(err)
}
}
pub type ProxyServiceResult<T> = Result<T, ProxyServiceError>;
pub enum ProxyType {
Nginx,
}
pub trait Mergeable<T> {
// merge with other, self overrides other
fn merge(&mut self, other: T);
}
#[derive(Debug, Clone)]
pub struct OverrideRef {
pub id: uuid::Uuid,
pub override_of_id: Option<uuid::Uuid>,
}
pub fn merge_override_vecs(
mut child: Vec<OverrideRef>,
parent: Vec<OverrideRef>,
) -> Vec<OverrideRef> {
let overridden_ids: std::collections::HashSet<uuid::Uuid> = child
.iter()
.filter_map(|r| r.override_of_id)
.collect();
for item in parent {
if !overridden_ids.contains(&item.id) {
child.push(item);
}
}
child
}
pub trait Overridable {
fn override_of_id(&self) -> Option<uuid::Uuid>;
}
pub struct ProxyConfig {
pub id: uuid::Uuid,
pub name: String,
pub r#type: ProxyType,
pub description: Option<String>,
pub parent_config_id: Option<Vec<uuid::Uuid>>,
//
pub server_blocks: HashMap<uuid::Uuid, ServerBlockConfig>,
pub upstreams: HashMap<uuid::Uuid, UpstreamConfig>,
pub access_rules: HashMap<uuid::Uuid, AccessRuleConfig>,
pub cache_zones: HashMap<uuid::Uuid, CacheZoneConfig>,
pub limit_rules: HashMap<uuid::Uuid, LimitRuleConfig>,
pub limit_zones: HashMap<uuid::Uuid, LimitZoneConfig>,
pub location_blocks: HashMap<uuid::Uuid, LocationBlockConfig>,
pub log_settings: HashMap<uuid::Uuid, LogSettingConfig>,
pub proxy_settings: HashMap<uuid::Uuid, ProxySettingConfig>,
pub rewrite_rules: HashMap<uuid::Uuid, RewriteRuleConfig>,
pub ssl_certificates: HashMap<uuid::Uuid, SslCertificateConfig>,
}
impl Mergeable<ProxyConfig> for ProxyConfig {
fn merge(&mut self, other: ProxyConfig) {
use std::collections::HashSet;
macro_rules! merge_overridable_field {
($field:ident) => {
let overridden: HashSet<uuid::Uuid> = self.$field
.values()
.filter_map(|v| v.override_of_id())
.collect();
for (id, value) in other.$field {
if !overridden.contains(&id) && !self.$field.contains_key(&id) {
self.$field.insert(id, value);
}
}
};
}
// server_blocks: merge matching entries (field-level), handle overrides
{
let overridden: HashSet<uuid::Uuid> = self.server_blocks
.values()
.filter_map(|sb| sb.override_of_id)
.collect();
for (id, block) in other.server_blocks {
if let Some(self_block) = self.server_blocks.get_mut(&id) {
self_block.merge(block);
} else if !overridden.contains(&id) {
self.server_blocks.insert(id, block);
}
}
}
merge_overridable_field!(upstreams);
merge_overridable_field!(access_rules);
merge_overridable_field!(cache_zones);
merge_overridable_field!(limit_rules);
merge_overridable_field!(limit_zones);
merge_overridable_field!(location_blocks);
merge_overridable_field!(log_settings);
merge_overridable_field!(proxy_settings);
merge_overridable_field!(rewrite_rules);
// ssl_certificates: simple merge (no override_of_id)
for (id, cert) in other.ssl_certificates {
self.ssl_certificates.entry(id).or_insert(cert);
}
}
}
pub struct ServerBlockConfig {
pub id: uuid::Uuid,
pub server_name: Option<Vec<String>>,
pub listen_port: i32,
pub ssl_enabled: Option<bool>,
pub override_of_id: Option<uuid::Uuid>,
//
pub access_rules: Vec<OverrideRef>,
pub location_blocks: Vec<OverrideRef>,
pub log_settings: Vec<OverrideRef>,
pub ssl_certificates: Vec<OverrideRef>,
}
impl
From<(
crate::db::entities::server_block::Model,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
)> for ServerBlockConfig
{
fn from(
(model, access_rules, location_blocks, log_settings, ssl_certificates): (
crate::db::entities::server_block::Model,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
),
) -> Self {
ServerBlockConfig {
id: model.id,
server_name: model.server_name,
listen_port: model.listen_port,
ssl_enabled: model.ssl_enabled,
override_of_id: model.override_of_id,
access_rules,
location_blocks,
log_settings,
ssl_certificates,
}
}
}
impl Mergeable<ServerBlockConfig> for ServerBlockConfig {
fn merge(&mut self, other: ServerBlockConfig) {
if let Some(server_name) = other.server_name {
self.server_name = Some(server_name);
}
self.listen_port = other.listen_port;
if let Some(ssl_enabled) = other.ssl_enabled {
self.ssl_enabled = Some(ssl_enabled);
}
self.override_of_id = other.override_of_id;
self.access_rules = merge_override_vecs(
std::mem::take(&mut self.access_rules),
other.access_rules,
);
self.location_blocks = merge_override_vecs(
std::mem::take(&mut self.location_blocks),
other.location_blocks,
);
self.log_settings = merge_override_vecs(
std::mem::take(&mut self.log_settings),
other.log_settings,
);
self.ssl_certificates = merge_override_vecs(
std::mem::take(&mut self.ssl_certificates),
other.ssl_certificates,
);
}
}
pub struct UpstreamConfig {
pub id: uuid::Uuid,
pub name: String,
pub target_host: String,
pub target_port: i32,
pub metadata: Option<serde_json::Value>,
pub override_of_id: Option<uuid::Uuid>,
//
pub location_blocks: Vec<OverrideRef>,
}
impl From<(crate::db::entities::upstream::Model, Vec<OverrideRef>)> for UpstreamConfig {
fn from(
(model, location_blocks): (crate::db::entities::upstream::Model, Vec<OverrideRef>),
) -> Self {
UpstreamConfig {
id: model.id,
name: model.name,
target_host: model.target_host,
target_port: model.target_port,
metadata: model.metadata,
override_of_id: model.override_of_id,
location_blocks,
}
}
}
pub struct AccessRuleConfig {
pub id: uuid::Uuid,
pub r#type: String,
pub ip_cidr: String,
pub description: Option<String>,
pub priority: i32,
pub override_of_id: Option<uuid::Uuid>,
}
impl From<crate::db::entities::access_rule::Model> for AccessRuleConfig {
fn from(model: crate::db::entities::access_rule::Model) -> Self {
AccessRuleConfig {
id: model.id,
r#type: model.r#type,
ip_cidr: model.ip_cidr,
description: model.description,
priority: model.priority,
override_of_id: model.override_of_id,
}
}
}
pub struct CacheZoneConfig {
pub id: uuid::Uuid,
pub name: String,
pub size: String,
pub override_of_id: Option<uuid::Uuid>,
}
impl From<crate::db::entities::cache_zone::Model> for CacheZoneConfig {
fn from(model: crate::db::entities::cache_zone::Model) -> Self {
CacheZoneConfig {
id: model.id,
name: model.name,
size: model.size_limit,
override_of_id: model.override_of_id,
}
}
}
pub struct LimitRuleConfig {
pub id: uuid::Uuid,
pub location_id: uuid::Uuid,
pub zone_id: uuid::Uuid,
pub burst: Option<i32>,
pub nodelay: Option<bool>,
pub is_deleted: bool,
pub override_of_id: Option<uuid::Uuid>,
}
impl From<crate::db::entities::limit_rule::Model> for LimitRuleConfig {
fn from(model: crate::db::entities::limit_rule::Model) -> Self {
LimitRuleConfig {
id: model.id,
location_id: model.location_id,
zone_id: model.zone_id,
burst: model.burst,
nodelay: model.nodelay,
is_deleted: model.is_deleted,
override_of_id: model.override_of_id,
}
}
}
pub struct LimitZoneConfig {
pub id: uuid::Uuid,
pub name: String,
pub key: String,
pub rate: String,
pub override_of_id: Option<uuid::Uuid>,
}
impl From<crate::db::entities::limit_zone::Model> for LimitZoneConfig {
fn from(model: crate::db::entities::limit_zone::Model) -> Self {
LimitZoneConfig {
id: model.id,
name: model.name,
key: model.key,
rate: model.rate,
override_of_id: model.override_of_id,
}
}
}
pub struct LocationBlockConfig {
pub id: uuid::Uuid,
pub server_id: uuid::Uuid,
pub path_pattern: String,
pub proxy_pass_upstream_id: Option<uuid::Uuid>,
pub metadata: Option<serde_json::Value>,
pub override_of_id: Option<uuid::Uuid>,
//
pub access_rules: Vec<OverrideRef>,
pub limit_rules: Vec<OverrideRef>,
pub proxy_settings: Vec<OverrideRef>,
pub rewrite_rules: Vec<OverrideRef>,
}
impl
From<(
crate::db::entities::location_block::Model,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
)> for LocationBlockConfig
{
fn from(
(model, access_rules, limit_rules, proxy_settings, rewrite_rules): (
crate::db::entities::location_block::Model,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
Vec<OverrideRef>,
),
) -> Self {
LocationBlockConfig {
id: model.id,
server_id: model.server_id,
path_pattern: model.path_pattern,
proxy_pass_upstream_id: model.proxy_pass_upstream_id,
metadata: model.metadata,
override_of_id: model.override_of_id,
access_rules,
limit_rules,
proxy_settings,
rewrite_rules,
}
}
}
pub struct LogSettingConfig {
pub id: uuid::Uuid,
pub access_log_path: Option<String>,
pub error_log_path: Option<String>,
pub log_level: Option<String>,
pub override_of_id: Option<uuid::Uuid>,
}
impl From<crate::db::entities::log_setting::Model> for LogSettingConfig {
fn from(model: crate::db::entities::log_setting::Model) -> Self {
LogSettingConfig {
id: model.id,
access_log_path: model.access_log_path,
error_log_path: model.error_log_path,
log_level: model.log_level,
override_of_id: model.override_of_id,
}
}
}
pub struct SslCertificateConfig {
pub id: uuid::Uuid,
pub name: String,
pub cert_path: String,
pub key_path: String,
pub expiry_date: chrono::DateTime<chrono::Utc>,
}
impl From<crate::db::entities::ssl_certificate::Model> for SslCertificateConfig {
fn from(model: crate::db::entities::ssl_certificate::Model) -> Self {
SslCertificateConfig {
id: model.id,
name: model.name,
cert_path: model.cert_path,
key_path: model.key_path,
expiry_date: model.expiry_date.and_utc(),
}
}
}
pub struct ProxySettingConfig {
pub id: uuid::Uuid,
pub location_id: uuid::Uuid,
pub read_timeout: Option<i32>,
pub connect_timeout: Option<i32>,
pub buffer_size: Option<i32>,
pub cache_enabled: Option<bool>,
pub cache_zone: Option<uuid::Uuid>,
pub override_of_id: Option<uuid::Uuid>,
}
impl From<crate::db::entities::proxy_setting::Model> for ProxySettingConfig {
fn from(model: crate::db::entities::proxy_setting::Model) -> Self {
ProxySettingConfig {
id: model.id,
location_id: model.location_id,
read_timeout: model.read_timeout,
connect_timeout: model.connect_timeout,
buffer_size: model.buffer_size,
cache_enabled: model.cache_enabled,
cache_zone: model.cache_zone,
override_of_id: model.override_of_id,
}
}
}
pub struct RewriteRuleConfig {
pub id: uuid::Uuid,
pub location_id: uuid::Uuid,
pub pattern: String,
pub replacement: String,
pub flag: Option<String>,
pub priority: i32,
pub override_of_id: Option<uuid::Uuid>,
}
impl From<crate::db::entities::rewrite_rule::Model> for RewriteRuleConfig {
fn from(model: crate::db::entities::rewrite_rule::Model) -> Self {
RewriteRuleConfig {
id: model.id,
location_id: model.location_id,
pattern: model.pattern,
replacement: model.replacement,
flag: model.flag,
priority: model.priority,
override_of_id: model.override_of_id,
}
}
}
// ── Overridable implementations ──
impl Overridable for ServerBlockConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for UpstreamConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for AccessRuleConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for CacheZoneConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for LimitRuleConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for LimitZoneConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for LocationBlockConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for LogSettingConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for ProxySettingConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}
impl Overridable for RewriteRuleConfig {
fn override_of_id(&self) -> Option<uuid::Uuid> { self.override_of_id }
}

View File

@@ -1,5 +1,3 @@
agent_id = "agent-id-01"
[grpc]
connection_string = "https://127.0.0.1:8443"

View File

@@ -2,7 +2,6 @@ pub use sea_orm_migration::prelude::*;
mod m20260301_000001_create_agents;
mod m20260301_000002_create_public_key_revokaction;
mod m20260620_111325_create_proxy_tables;
pub struct Migrator;
@@ -12,7 +11,6 @@ impl MigratorTrait for Migrator {
vec![
Box::new(m20260301_000001_create_agents::Migration),
Box::new(m20260301_000002_create_public_key_revokaction::Migration),
Box::new(m20260620_111325_create_proxy_tables::Migration),
]
}
}

View File

@@ -1,902 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(AgentGroup::Table)
.if_not_exists()
.col(
ColumnDef::new(AgentGroup::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(AgentGroup::Name).string().not_null())
.col(ColumnDef::new(AgentGroup::Description).string())
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Agents::Table)
.add_column(ColumnDef::new(Agents::GroupId).uuid().null())
.add_foreign_key(
TableForeignKey::new()
.name("fk_agents_group_id")
.from_tbl(Agents::Table)
.to_tbl(AgentGroup::Table)
.from_col(Agents::GroupId)
.to_col(AgentGroup::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ProxyConfig::Table)
.if_not_exists()
.col(
ColumnDef::new(ProxyConfig::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(ProxyConfig::Name).string().not_null())
.col(ColumnDef::new(ProxyConfig::Description).string())
.col(ColumnDef::new(ProxyConfig::IsTemplate).boolean().not_null())
.col(
ColumnDef::new(ProxyConfig::CreatedAt)
.timestamp()
.not_null(),
)
.col(
ColumnDef::new(ProxyConfig::UpdatedAt)
.timestamp()
.not_null(),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(SSLCertificate::Table)
.if_not_exists()
.col(
ColumnDef::new(SSLCertificate::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(SSLCertificate::Name).string().not_null())
.col(ColumnDef::new(SSLCertificate::CertPath).string().not_null())
.col(ColumnDef::new(SSLCertificate::KeyPath).string().not_null())
.col(
ColumnDef::new(SSLCertificate::ExpiryDate)
.timestamp()
.not_null(),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(CacheZone::Table)
.if_not_exists()
.col(
ColumnDef::new(CacheZone::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(CacheZone::Name).string().not_null())
.col(ColumnDef::new(CacheZone::Path).string().not_null())
.col(ColumnDef::new(CacheZone::SizeLimit).string().not_null())
.col(ColumnDef::new(CacheZone::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_cache_zone_override_of")
.from_tbl(CacheZone::Table)
.to_tbl(CacheZone::Table)
.from_col(CacheZone::OverrideOfId)
.to_col(CacheZone::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(LimitZone::Table)
.if_not_exists()
.col(
ColumnDef::new(LimitZone::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(LimitZone::Name).string().not_null())
.col(ColumnDef::new(LimitZone::Key).string().not_null())
.col(ColumnDef::new(LimitZone::Rate).string().not_null())
.col(ColumnDef::new(LimitZone::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_limit_zone_override_of")
.from_tbl(LimitZone::Table)
.to_tbl(LimitZone::Table)
.from_col(LimitZone::OverrideOfId)
.to_col(LimitZone::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(AgentConfigBinding::Table)
.if_not_exists()
.col(
ColumnDef::new(AgentConfigBinding::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(AgentConfigBinding::AgentId).uuid())
.col(ColumnDef::new(AgentConfigBinding::GroupId).uuid())
.col(
ColumnDef::new(AgentConfigBinding::ConfigId)
.uuid()
.not_null(),
)
.col(
ColumnDef::new(AgentConfigBinding::IsActive)
.boolean()
.not_null(),
)
.col(
ColumnDef::new(AgentConfigBinding::AppliedAt)
.timestamp()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_agent_config_binding_agent")
.from_tbl(AgentConfigBinding::Table)
.to_tbl(Agents::Table)
.from_col(AgentConfigBinding::AgentId)
.to_col(Agents::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_agent_config_binding_group")
.from_tbl(AgentConfigBinding::Table)
.to_tbl(AgentGroup::Table)
.from_col(AgentConfigBinding::GroupId)
.to_col(AgentGroup::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_agent_config_binding_config")
.from_tbl(AgentConfigBinding::Table)
.to_tbl(ProxyConfig::Table)
.from_col(AgentConfigBinding::ConfigId)
.to_col(ProxyConfig::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ConfigInheritance::Table)
.if_not_exists()
.col(
ColumnDef::new(ConfigInheritance::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(ConfigInheritance::ChildConfigId)
.uuid()
.not_null(),
)
.col(
ColumnDef::new(ConfigInheritance::ParentConfigId)
.uuid()
.not_null(),
)
.col(ColumnDef::new(ConfigInheritance::Priority).integer())
.col(
ColumnDef::new(ConfigInheritance::AppliedAt)
.timestamp()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_config_inheritance_child")
.from_tbl(ConfigInheritance::Table)
.to_tbl(ProxyConfig::Table)
.from_col(ConfigInheritance::ChildConfigId)
.to_col(ProxyConfig::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_config_inheritance_parent")
.from_tbl(ConfigInheritance::Table)
.to_tbl(ProxyConfig::Table)
.from_col(ConfigInheritance::ParentConfigId)
.to_col(ProxyConfig::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Upstream::Table)
.if_not_exists()
.col(ColumnDef::new(Upstream::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Upstream::ConfigId).uuid().not_null())
.col(ColumnDef::new(Upstream::Name).string().not_null())
.col(ColumnDef::new(Upstream::TargetHost).string().not_null())
.col(ColumnDef::new(Upstream::TargetPort).integer().not_null())
.col(ColumnDef::new(Upstream::Metadata).json_binary())
.col(ColumnDef::new(Upstream::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_upstream_config")
.from_tbl(Upstream::Table)
.to_tbl(ProxyConfig::Table)
.from_col(Upstream::ConfigId)
.to_col(ProxyConfig::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_upstream_override_of")
.from_tbl(Upstream::Table)
.to_tbl(Upstream::Table)
.from_col(Upstream::OverrideOfId)
.to_col(Upstream::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ServerBlock::Table)
.if_not_exists()
.col(
ColumnDef::new(ServerBlock::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(ServerBlock::ConfigId).uuid().not_null())
.col(
ColumnDef::new(ServerBlock::ServerName)
.array(ColumnType::String(StringLen::None)),
)
.col(ColumnDef::new(ServerBlock::ListenPort).integer().not_null())
.col(ColumnDef::new(ServerBlock::SslEnabled).boolean())
.col(ColumnDef::new(ServerBlock::SslCertId).uuid())
.col(ColumnDef::new(ServerBlock::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_server_block_config")
.from_tbl(ServerBlock::Table)
.to_tbl(ProxyConfig::Table)
.from_col(ServerBlock::ConfigId)
.to_col(ProxyConfig::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_server_block_ssl_cert")
.from_tbl(ServerBlock::Table)
.to_tbl(SSLCertificate::Table)
.from_col(ServerBlock::SslCertId)
.to_col(SSLCertificate::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_server_block_override_of")
.from_tbl(ServerBlock::Table)
.to_tbl(ServerBlock::Table)
.from_col(ServerBlock::OverrideOfId)
.to_col(ServerBlock::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(LocationBlock::Table)
.if_not_exists()
.col(
ColumnDef::new(LocationBlock::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(LocationBlock::ServerId).uuid().not_null())
.col(
ColumnDef::new(LocationBlock::PathPattern)
.string()
.not_null(),
)
.col(ColumnDef::new(LocationBlock::ProxyPassUpstreamId).uuid())
.col(ColumnDef::new(LocationBlock::Metadata).json_binary())
.col(ColumnDef::new(LocationBlock::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_location_block_server")
.from_tbl(LocationBlock::Table)
.to_tbl(ServerBlock::Table)
.from_col(LocationBlock::ServerId)
.to_col(ServerBlock::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_location_block_upstream")
.from_tbl(LocationBlock::Table)
.to_tbl(Upstream::Table)
.from_col(LocationBlock::ProxyPassUpstreamId)
.to_col(Upstream::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_location_block_override_of")
.from_tbl(LocationBlock::Table)
.to_tbl(LocationBlock::Table)
.from_col(LocationBlock::OverrideOfId)
.to_col(LocationBlock::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(AccessRule::Table)
.if_not_exists()
.col(
ColumnDef::new(AccessRule::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(AccessRule::ServerId).uuid())
.col(ColumnDef::new(AccessRule::LocationId).uuid())
.col(
ColumnDef::new(AccessRule::Type)
.string()
.not_null()
.comment("allow or deny"),
)
.col(
ColumnDef::new(AccessRule::IpCidr)
.string()
.not_null()
.comment("IP address or CIDR range. 0.0.0.0/0 means all IPs"),
)
.col(ColumnDef::new(AccessRule::Description).string())
.col(
ColumnDef::new(AccessRule::Priority)
.integer()
.not_null()
.comment(
"Priority of the access rule. Lower number means higher priority.",
),
)
.col(ColumnDef::new(AccessRule::IsDeleted).boolean().not_null())
.col(ColumnDef::new(AccessRule::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_access_rule_server")
.from_tbl(AccessRule::Table)
.to_tbl(ServerBlock::Table)
.from_col(AccessRule::ServerId)
.to_col(ServerBlock::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_access_rule_location")
.from_tbl(AccessRule::Table)
.to_tbl(LocationBlock::Table)
.from_col(AccessRule::LocationId)
.to_col(LocationBlock::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_access_rule_override_of")
.from_tbl(AccessRule::Table)
.to_tbl(AccessRule::Table)
.from_col(AccessRule::OverrideOfId)
.to_col(AccessRule::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(RewriteRule::Table)
.if_not_exists()
.col(
ColumnDef::new(RewriteRule::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(RewriteRule::LocationId).uuid().not_null())
.col(ColumnDef::new(RewriteRule::Pattern).string().not_null())
.col(ColumnDef::new(RewriteRule::Replacement).string().not_null())
.col(ColumnDef::new(RewriteRule::Flag).string())
.col(ColumnDef::new(RewriteRule::Priority).integer().not_null())
.col(ColumnDef::new(RewriteRule::IsDeleted).boolean().not_null())
.col(ColumnDef::new(RewriteRule::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_rewrite_rule_location")
.from_tbl(RewriteRule::Table)
.to_tbl(LocationBlock::Table)
.from_col(RewriteRule::LocationId)
.to_col(LocationBlock::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_rewrite_rule_override_of")
.from_tbl(RewriteRule::Table)
.to_tbl(RewriteRule::Table)
.from_col(RewriteRule::OverrideOfId)
.to_col(RewriteRule::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ProxySetting::Table)
.if_not_exists()
.col(
ColumnDef::new(ProxySetting::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(ProxySetting::LocationId).uuid().not_null())
.col(ColumnDef::new(ProxySetting::ReadTimeout).integer())
.col(ColumnDef::new(ProxySetting::ConnectTimeout).integer())
.col(ColumnDef::new(ProxySetting::BufferSize).integer())
.col(ColumnDef::new(ProxySetting::CacheEnabled).boolean())
.col(ColumnDef::new(ProxySetting::CacheZone).uuid())
.col(ColumnDef::new(ProxySetting::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_proxy_setting_location")
.from_tbl(ProxySetting::Table)
.to_tbl(LocationBlock::Table)
.from_col(ProxySetting::LocationId)
.to_col(LocationBlock::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_proxy_setting_cache_zone")
.from_tbl(ProxySetting::Table)
.to_tbl(CacheZone::Table)
.from_col(ProxySetting::CacheZone)
.to_col(CacheZone::Id)
.on_delete(ForeignKeyAction::SetNull),
)
.foreign_key(
ForeignKey::create()
.name("fk_proxy_setting_override_of")
.from_tbl(ProxySetting::Table)
.to_tbl(ProxySetting::Table)
.from_col(ProxySetting::OverrideOfId)
.to_col(ProxySetting::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(LimitRule::Table)
.if_not_exists()
.col(
ColumnDef::new(LimitRule::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(LimitRule::LocationId).uuid().not_null())
.col(ColumnDef::new(LimitRule::ZoneId).uuid().not_null())
.col(ColumnDef::new(LimitRule::Burst).integer())
.col(ColumnDef::new(LimitRule::Nodelay).boolean())
.col(ColumnDef::new(LimitRule::IsDeleted).boolean().not_null())
.col(ColumnDef::new(LimitRule::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_limit_rule_location")
.from_tbl(LimitRule::Table)
.to_tbl(LocationBlock::Table)
.from_col(LimitRule::LocationId)
.to_col(LocationBlock::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_limit_rule_zone")
.from_tbl(LimitRule::Table)
.to_tbl(LimitZone::Table)
.from_col(LimitRule::ZoneId)
.to_col(LimitZone::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_limit_rule_override_of")
.from_tbl(LimitRule::Table)
.to_tbl(LimitRule::Table)
.from_col(LimitRule::OverrideOfId)
.to_col(LimitRule::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(LogSetting::Table)
.if_not_exists()
.col(
ColumnDef::new(LogSetting::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(LogSetting::ServerId).uuid().not_null())
.col(ColumnDef::new(LogSetting::AccessLogPath).string())
.col(ColumnDef::new(LogSetting::ErrorLogPath).string())
.col(ColumnDef::new(LogSetting::LogLevel).string())
.col(ColumnDef::new(LogSetting::OverrideOfId).uuid())
.foreign_key(
ForeignKey::create()
.name("fk_log_setting_server")
.from_tbl(LogSetting::Table)
.to_tbl(ServerBlock::Table)
.from_col(LogSetting::ServerId)
.to_col(ServerBlock::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_log_setting_override_of")
.from_tbl(LogSetting::Table)
.to_tbl(LogSetting::Table)
.from_col(LogSetting::OverrideOfId)
.to_col(LogSetting::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(LogSetting::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(LimitRule::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ProxySetting::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(RewriteRule::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(AccessRule::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(LocationBlock::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ServerBlock::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Upstream::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ConfigInheritance::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(AgentConfigBinding::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(LimitZone::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(CacheZone::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(SSLCertificate::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ProxyConfig::Table).to_owned())
.await?;
manager
.alter_table(
Table::alter()
.table(Agents::Table)
.drop_foreign_key(Alias::new("fk_agents_group_id"))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Agents::Table)
.drop_column(Agents::GroupId)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(AgentGroup::Table).to_owned())
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Agents {
Table,
Id,
GroupId,
}
#[derive(DeriveIden)]
enum AgentGroup {
Table,
Id,
Name,
Description,
}
#[derive(DeriveIden)]
enum AgentConfigBinding {
Table,
Id,
AgentId,
GroupId,
ConfigId,
IsActive,
AppliedAt,
}
#[derive(DeriveIden)]
enum ProxyConfig {
Table,
Id,
Name,
Description,
IsTemplate,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum ConfigInheritance {
Table,
Id,
ChildConfigId,
ParentConfigId,
Priority,
AppliedAt,
}
#[derive(DeriveIden)]
enum SSLCertificate {
Table,
Id,
Name,
CertPath,
KeyPath,
ExpiryDate,
}
#[derive(DeriveIden)]
enum CacheZone {
Table,
Id,
Name,
Path,
SizeLimit,
OverrideOfId,
}
#[derive(DeriveIden)]
enum LimitZone {
Table,
Id,
Name,
Key,
Rate,
OverrideOfId,
}
#[derive(DeriveIden)]
enum Upstream {
Table,
Id,
ConfigId,
Name,
TargetHost,
TargetPort,
Metadata,
OverrideOfId,
}
#[derive(DeriveIden)]
enum ServerBlock {
Table,
Id,
ConfigId,
ServerName,
ListenPort,
SslEnabled,
SslCertId,
OverrideOfId,
}
#[derive(DeriveIden)]
enum LocationBlock {
Table,
Id,
ServerId,
PathPattern,
ProxyPassUpstreamId,
Metadata,
OverrideOfId,
}
#[derive(DeriveIden)]
enum AccessRule {
Table,
Id,
ServerId,
LocationId,
Type,
IpCidr,
Priority,
Description,
IsDeleted,
OverrideOfId,
}
#[derive(DeriveIden)]
enum RewriteRule {
Table,
Id,
LocationId,
Pattern,
Replacement,
Flag,
Priority,
IsDeleted,
OverrideOfId,
}
#[derive(DeriveIden)]
enum ProxySetting {
Table,
Id,
LocationId,
ReadTimeout,
ConnectTimeout,
BufferSize,
CacheEnabled,
CacheZone,
OverrideOfId,
}
#[derive(DeriveIden)]
enum LimitRule {
Table,
Id,
LocationId,
ZoneId,
Burst,
Nodelay,
IsDeleted,
OverrideOfId,
}
#[derive(DeriveIden)]
enum LogSetting {
Table,
Id,
ServerId,
AccessLogPath,
ErrorLogPath,
LogLevel,
OverrideOfId,
}

View File

@@ -4,7 +4,6 @@ fn main() -> Result<()> {
tonic_prost_build::configure()
.build_server(true)
.build_client(true)
.protoc_arg("--experimental_allow_proto3_optional")
.compile_protos(&["proto/agent.proto"], &["proto"])?;
Ok(())
}

View File

@@ -11,53 +11,103 @@ service AgentService {
// Stream establishes a persistent connection for real-time communication
rpc Stream(stream AgentMessage) returns (stream MasterMessage);
rpc ConnectionTest(TestRequest) returns (TestResponse);
}
// ReportHealth sends a health report to the master
rpc ReportHealth(HealthReport) returns (Ack);
message TestRequest {
// 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;
}
// ReportMetrics sends metrics batch to the master
rpc ReportMetrics(MetricsBatch) returns (Ack);
}
// Messages sent from agent to master
message AgentMessage {
string agent_id = 1;
int64 timestamp = 2;
string message_id = 3;
oneof payload {
// responses
ConfigUpdateResult config_update_result = 6;
CommandResult command_result = 7;
RegistrationRequest registration = 3;
HealthReport health = 4;
ConfigStatus config_status = 5;
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;
}
}
// ConfigUpdate represents a request from master to agent to update the configuration
// Registration
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 {
string config_id = 1; // unique identifier for this config update
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.
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.
repeated ConfigContent configs = 4;
string config_id = 1;
int64 version = 2;
repeated ConfigContent configs = 3;
repeated CertificateContent certificates = 4;
}
message ConfigContent {
@@ -66,56 +116,113 @@ message ConfigContent {
string content = 2;
}
message ConfigUpdateResult {
string config_id = 1; // should match the config_id in ConfigUpdate
string version = 2;
bool success = 3;
optional ConfigUpdateError error_message = 4; // if success is false, this field should contain the error message
message CertificateContent {
string id = 1;
// relative path from other config files, e.g. "certs/example.com.pem"
string path = 2;
string certificate_pem = 3;
string private_key_pem = 4;
}
enum ConfigUpdateError {
UNKNOWN = 0;
INVALID_CONFIG = 1; // the config content is invalid, e.g. syntax error
WRITE_FAILED = 2; // failed to write the config file to disk
RELOAD_FAILED = 3; // failed to reload nginx with the new config
message ConfigStatus {
string config_id = 1;
int64 version = 2;
ConfigApplyStatus status = 3;
string error_message = 4;
int64 applied_at = 5;
}
//
//
//
enum ConfigApplyStatus {
CONFIG_APPLY_STATUS_UNSPECIFIED = 0;
PENDING = 1;
VALIDATING = 2;
APPLYING = 3;
SUCCESS = 4;
FAILED = 5;
ROLLED_BACK = 6;
}
// TODO: allow setting the default fallback and the corresponding default nginx root config when nginx reload fails to re-use old config, "use default config", "stop nginx".
// Metrics
message MetricsBatch {
int64 timestamp = 1;
repeated Metric metrics = 2;
}
// Command represents a request from master to agent to execute a command, e.g. "reload", "test"
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 {
string command_id = 1;
oneof command {
ReloadCommand reload = 1;
TestCommand test = 2;
ReloadCommand reload = 2;
RestartCommand restart = 3;
StopCommand stop = 4;
GetStatusCommand get_status = 5;
ValidateConfigCommand validate_config = 6;
}
}
message ReloadCommand {
// no additional fields needed for reload command
bool graceful = 1;
}
message TestCommand {
// no additional fields needed for test command
message RestartCommand {
bool force = 1;
}
message CommandResult {
oneof result {
ReloadResult reload_result = 1;
TestResult test_result = 2;
}
message StopCommand {
bool graceful = 1;
uint32 timeout_seconds = 2;
}
message ReloadResult {
bool success = 1;
string error_message = 2; // if success is false, this field should contain the error message
message GetStatusCommand {}
message ValidateConfigCommand {
string config_content = 1;
}
message TestResult {
bool success = 1;
string error_message = 2; // if success is false, this field should contain the error message
// Events
message Event {
string event_id = 1;
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;
}

View File

@@ -38,7 +38,7 @@ setup-frontend:
act *ARGS:
# run act with custom secret-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 --artifact-server-path ./.act/.artifacts {{ ARGS }}
act --env-file .github/.env --secret-file .github/.secrets.env --var-file .github/.var.env --network host {{ ARGS }}
# Start all services for development
dev: