cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "nx9-wg-core"
|
||||
description = "Core domain types, configuration, and utilities for nx9-wg"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
argon2.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
rand.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
ipnet.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Application configuration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AppConfig {
|
||||
pub data_dir: PathBuf,
|
||||
pub config_file: PathBuf,
|
||||
pub bind_address: SocketAddr,
|
||||
pub log_level: String,
|
||||
pub session_expiry_hours: u64,
|
||||
pub reconciliation_interval_secs: u64,
|
||||
pub backup: BackupConfig,
|
||||
pub bootstrap: Option<BootstrapConfig>,
|
||||
pub admin: Option<AdminConfig>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: PathBuf::from("/var/lib/nx9-wg"),
|
||||
config_file: PathBuf::from("/etc/nx9-wg/config.toml"),
|
||||
bind_address: "127.0.0.1:8080".parse().unwrap(),
|
||||
log_level: "info".to_string(),
|
||||
session_expiry_hours: 24,
|
||||
reconciliation_interval_secs: 60,
|
||||
backup: BackupConfig::default(),
|
||||
bootstrap: None,
|
||||
admin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BackupConfig {
|
||||
pub dir: PathBuf,
|
||||
pub max_count: usize,
|
||||
pub schedule: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BackupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dir: PathBuf::from("/var/lib/nx9-wg/backups"),
|
||||
max_count: 5,
|
||||
schedule: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct BootstrapConfig {
|
||||
pub admin_username: Option<String>,
|
||||
pub admin_password: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BootstrapConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BootstrapConfig")
|
||||
.field("admin_username", &self.admin_username)
|
||||
.field(
|
||||
"admin_password",
|
||||
&self.admin_password.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AdminConfig {
|
||||
pub username: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AdminConfig")
|
||||
.field("username", &self.username)
|
||||
.field(
|
||||
"password_hash",
|
||||
&self.password_hash.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Load configuration from a TOML file, falling back to defaults, then overlaying `NX9_WG_` environment variables.
|
||||
pub fn load(path: &std::path::Path) -> crate::error::Result<Self> {
|
||||
let mut config = if path.exists() {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
toml::from_str(&content).map_err(|e| crate::error::Nx9Error::Config(e.to_string()))?
|
||||
} else {
|
||||
Self::default()
|
||||
};
|
||||
config.apply_env_overrides()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Apply `NX9_WG_` environment variable overrides to this configuration instance.
|
||||
pub fn apply_env_overrides(&mut self) -> crate::error::Result<()> {
|
||||
if let Ok(val) = std::env::var("NX9_WG_DATA_DIR") {
|
||||
self.data_dir = PathBuf::from(val);
|
||||
}
|
||||
if let Ok(val) = std::env::var("NX9_WG_CONFIG") {
|
||||
self.config_file = PathBuf::from(val);
|
||||
}
|
||||
if let Ok(val) =
|
||||
std::env::var("NX9_WG_LISTEN_ADDR").or_else(|_| std::env::var("NX9_WG_BIND_ADDRESS"))
|
||||
{
|
||||
self.bind_address = val.parse().map_err(|e| {
|
||||
crate::error::Nx9Error::Config(format!("invalid NX9_WG_LISTEN_ADDR '{val}': {e}"))
|
||||
})?;
|
||||
}
|
||||
if let Ok(val) = std::env::var("NX9_WG_LOG_LEVEL") {
|
||||
self.log_level = val;
|
||||
}
|
||||
if let Ok(val) = std::env::var("NX9_WG_SESSION_TIMEOUT")
|
||||
.or_else(|_| std::env::var("NX9_WG_SESSION_EXPIRY_HOURS"))
|
||||
{
|
||||
self.session_expiry_hours = val.parse().map_err(|e| {
|
||||
crate::error::Nx9Error::Config(format!(
|
||||
"invalid NX9_WG_SESSION_TIMEOUT '{val}': {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if let Ok(val) = std::env::var("NX9_WG_RECONCILIATION_INTERVAL")
|
||||
.or_else(|_| std::env::var("NX9_WG_RECONCILIATION_INTERVAL_SECS"))
|
||||
{
|
||||
self.reconciliation_interval_secs = val.parse().map_err(|e| {
|
||||
crate::error::Nx9Error::Config(format!(
|
||||
"invalid NX9_WG_RECONCILIATION_INTERVAL '{val}': {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if let Ok(val) = std::env::var("NX9_WG_BACKUP_DIR") {
|
||||
self.backup.dir = PathBuf::from(val);
|
||||
}
|
||||
if let Ok(val) = std::env::var("NX9_WG_BACKUP_MAX_COUNT") {
|
||||
self.backup.max_count = val.parse().map_err(|e| {
|
||||
crate::error::Nx9Error::Config(format!(
|
||||
"invalid NX9_WG_BACKUP_MAX_COUNT '{val}': {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if let Ok(val) = std::env::var("NX9_WG_BACKUP_SCHEDULE") {
|
||||
self.backup.schedule = Some(val);
|
||||
}
|
||||
|
||||
// Bootstrap environment variables
|
||||
let env_user = std::env::var("NX9_WG_ADMIN_USERNAME").ok();
|
||||
let env_pass = std::env::var("NX9_WG_ADMIN_PASSWORD").ok();
|
||||
if env_user.is_some() || env_pass.is_some() {
|
||||
let mut boot = self.bootstrap.take().unwrap_or(BootstrapConfig {
|
||||
admin_username: None,
|
||||
admin_password: None,
|
||||
});
|
||||
if let Some(u) = env_user {
|
||||
boot.admin_username = Some(u);
|
||||
}
|
||||
if let Some(p) = env_pass {
|
||||
boot.admin_password = Some(p);
|
||||
}
|
||||
self.bootstrap = Some(boot);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let cfg = AppConfig::default();
|
||||
assert_eq!(cfg.log_level, "info");
|
||||
assert_eq!(cfg.session_expiry_hours, 24);
|
||||
assert_eq!(cfg.reconciliation_interval_secs, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_redaction_in_debug() {
|
||||
let boot = BootstrapConfig {
|
||||
admin_username: Some("admin".to_string()),
|
||||
admin_password: Some("supersecret123".to_string()),
|
||||
};
|
||||
let debug_str = format!("{boot:?}");
|
||||
assert!(!debug_str.contains("supersecret123"));
|
||||
assert!(debug_str.contains("[REDACTED]"));
|
||||
|
||||
let admin = AdminConfig {
|
||||
username: Some("admin".to_string()),
|
||||
password_hash: Some("$argon2id$...".to_string()),
|
||||
};
|
||||
let debug_str = format!("{admin:?}");
|
||||
assert!(!debug_str.contains("$argon2id$..."));
|
||||
assert!(debug_str.contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_overrides_application() {
|
||||
let mut cfg = AppConfig::default();
|
||||
unsafe {
|
||||
std::env::set_var("NX9_WG_DATA_DIR", "/tmp/nx9_test_data");
|
||||
std::env::set_var("NX9_WG_LOG_LEVEL", "debug");
|
||||
std::env::set_var("NX9_WG_SESSION_TIMEOUT", "48");
|
||||
}
|
||||
|
||||
cfg.apply_env_overrides().unwrap();
|
||||
|
||||
assert_eq!(cfg.data_dir, PathBuf::from("/tmp/nx9_test_data"));
|
||||
assert_eq!(cfg.log_level, "debug");
|
||||
assert_eq!(cfg.session_expiry_hours, 48);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("NX9_WG_DATA_DIR");
|
||||
std::env::remove_var("NX9_WG_LOG_LEVEL");
|
||||
std::env::remove_var("NX9_WG_SESSION_TIMEOUT");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Cryptographic utilities.
|
||||
|
||||
use crate::error::{Nx9Error, Result};
|
||||
use crate::types::wireguard::{WireGuardPresharedKey, WireGuardPrivateKey, WireGuardPublicKey};
|
||||
|
||||
/// Hash a password with Argon2id. Returns the PHC-formatted hash string.
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| Nx9Error::Crypto(format!("password hashing failed: {}", e)))
|
||||
}
|
||||
|
||||
/// Verify a password against an Argon2id PHC hash string.
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::{PasswordHash, PasswordVerifier};
|
||||
let parsed_hash = PasswordHash::new(hash)
|
||||
.map_err(|e| Nx9Error::Crypto(format!("invalid password hash: {}", e)))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
/// Generate a WireGuard key pair (x25519).
|
||||
pub fn generate_keypair() -> (WireGuardPrivateKey, WireGuardPublicKey) {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use rand::rngs::OsRng;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
let secret = StaticSecret::random_from_rng(OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
let priv_b64 = STANDARD.encode(secret.to_bytes());
|
||||
let pub_b64 = STANDARD.encode(public.as_bytes());
|
||||
(
|
||||
WireGuardPrivateKey::new(priv_b64),
|
||||
WireGuardPublicKey::new(pub_b64),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate a WireGuard preshared key (32 random bytes, base64).
|
||||
pub fn generate_preshared_key() -> WireGuardPresharedKey {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use rand::RngCore;
|
||||
let mut key = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut key);
|
||||
WireGuardPresharedKey::new(STANDARD.encode(key))
|
||||
}
|
||||
|
||||
/// Generate a session ID (UUID v4).
|
||||
pub fn generate_session_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Generate an API token. Returns (plaintext_token, sha256_hex_hash).
|
||||
pub fn generate_api_token() -> (String, String) {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut token_bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut token_bytes);
|
||||
let plaintext = format!("nx9_{}", URL_SAFE_NO_PAD.encode(token_bytes));
|
||||
let hash_bytes = Sha256::digest(plaintext.as_bytes());
|
||||
let hash = hash_bytes
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>();
|
||||
(plaintext, hash)
|
||||
}
|
||||
|
||||
/// Generate a cryptographically secure random password.
|
||||
pub fn generate_secure_password(length: usize) -> String {
|
||||
use rand::Rng;
|
||||
const CHARSET: &[u8] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_password_hashing() {
|
||||
let password = "my_secure_password";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_keypair() {
|
||||
let (priv_key, pub_key) = generate_keypair();
|
||||
assert!(!priv_key.as_str().is_empty());
|
||||
assert!(!pub_key.as_str().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_api_token() {
|
||||
let (token, hash) = generate_api_token();
|
||||
assert!(token.starts_with("nx9_"));
|
||||
assert_eq!(hash.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_secure_password() {
|
||||
let pw = generate_secure_password(16);
|
||||
assert_eq!(pw.len(), 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Error types for nx9-wg.
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Nx9Error {
|
||||
/// Validation error
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
/// Database error
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
/// WireGuard error
|
||||
#[error("WireGuard error: {0}")]
|
||||
WireGuard(String),
|
||||
/// Network error
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
/// Authentication error
|
||||
#[error("Auth error: {0}")]
|
||||
Auth(String),
|
||||
/// Cryptography error
|
||||
#[error("Crypto error: {0}")]
|
||||
Crypto(String),
|
||||
/// Backup error
|
||||
#[error("Backup error: {0}")]
|
||||
Backup(String),
|
||||
/// Configuration error
|
||||
#[error("Config error: {0}")]
|
||||
Config(String),
|
||||
/// I/O error
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Nx9Error>;
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Core domain types, configuration, and utilities for nx9-wg.
|
||||
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod types;
|
||||
pub mod validation;
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Audit types.
|
||||
|
||||
use crate::error::Nx9Error;
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditEventType {
|
||||
Login,
|
||||
Logout,
|
||||
LoginFailed,
|
||||
AdminInitialized,
|
||||
PasswordChange,
|
||||
TotpChange,
|
||||
ApiTokenCreate,
|
||||
ApiTokenRevoke,
|
||||
InterfaceCreate,
|
||||
InterfaceUpdate,
|
||||
InterfaceDelete,
|
||||
InterfaceEnable,
|
||||
InterfaceDisable,
|
||||
PeerCreate,
|
||||
PeerUpdate,
|
||||
PeerDelete,
|
||||
PeerEnable,
|
||||
PeerDisable,
|
||||
PeerRevoke,
|
||||
PeerExpire,
|
||||
PeerRotateKeys,
|
||||
NetworkCreate,
|
||||
NetworkUpdate,
|
||||
NetworkDelete,
|
||||
RouteCreate,
|
||||
RouteUpdate,
|
||||
RouteDelete,
|
||||
RouteEnable,
|
||||
RouteDisable,
|
||||
FirewallCreate,
|
||||
FirewallUpdate,
|
||||
FirewallDelete,
|
||||
BackupCreate,
|
||||
BackupRestore,
|
||||
BackupDelete,
|
||||
SessionInvalidated,
|
||||
SettingsUpdate,
|
||||
ReconciliationRun,
|
||||
ReconciliationFailed,
|
||||
ImportCompleted,
|
||||
}
|
||||
|
||||
impl AuditEventType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Login => "login",
|
||||
Self::Logout => "logout",
|
||||
Self::LoginFailed => "login_failed",
|
||||
Self::AdminInitialized => "admin_initialized",
|
||||
Self::PasswordChange => "password_change",
|
||||
Self::TotpChange => "totp_change",
|
||||
Self::ApiTokenCreate => "api_token_create",
|
||||
Self::ApiTokenRevoke => "api_token_revoke",
|
||||
Self::InterfaceCreate => "interface_create",
|
||||
Self::InterfaceUpdate => "interface_update",
|
||||
Self::InterfaceDelete => "interface_delete",
|
||||
Self::InterfaceEnable => "interface_enable",
|
||||
Self::InterfaceDisable => "interface_disable",
|
||||
Self::PeerCreate => "peer_create",
|
||||
Self::PeerUpdate => "peer_update",
|
||||
Self::PeerDelete => "peer_delete",
|
||||
Self::PeerEnable => "peer_enable",
|
||||
Self::PeerDisable => "peer_disable",
|
||||
Self::PeerRevoke => "peer_revoke",
|
||||
Self::PeerExpire => "peer_expire",
|
||||
Self::PeerRotateKeys => "peer_rotate_keys",
|
||||
Self::NetworkCreate => "network_create",
|
||||
Self::NetworkUpdate => "network_update",
|
||||
Self::NetworkDelete => "network_delete",
|
||||
Self::RouteCreate => "route_create",
|
||||
Self::RouteUpdate => "route_update",
|
||||
Self::RouteDelete => "route_delete",
|
||||
Self::RouteEnable => "route_enable",
|
||||
Self::RouteDisable => "route_disable",
|
||||
Self::FirewallCreate => "firewall_create",
|
||||
Self::FirewallUpdate => "firewall_update",
|
||||
Self::FirewallDelete => "firewall_delete",
|
||||
Self::BackupCreate => "backup_create",
|
||||
Self::BackupRestore => "backup_restore",
|
||||
Self::BackupDelete => "backup_delete",
|
||||
Self::SessionInvalidated => "session_invalidated",
|
||||
Self::SettingsUpdate => "settings_update",
|
||||
Self::ReconciliationRun => "reconciliation_run",
|
||||
Self::ReconciliationFailed => "reconciliation_failed",
|
||||
Self::ImportCompleted => "import_completed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AuditEventType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AuditEventType {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let v = match s {
|
||||
"login" => Self::Login,
|
||||
"logout" => Self::Logout,
|
||||
"login_failed" => Self::LoginFailed,
|
||||
"admin_initialized" => Self::AdminInitialized,
|
||||
"password_change" => Self::PasswordChange,
|
||||
"totp_change" => Self::TotpChange,
|
||||
"api_token_create" => Self::ApiTokenCreate,
|
||||
"api_token_revoke" => Self::ApiTokenRevoke,
|
||||
"interface_create" => Self::InterfaceCreate,
|
||||
"interface_update" => Self::InterfaceUpdate,
|
||||
"interface_delete" => Self::InterfaceDelete,
|
||||
"interface_enable" => Self::InterfaceEnable,
|
||||
"interface_disable" => Self::InterfaceDisable,
|
||||
"peer_create" => Self::PeerCreate,
|
||||
"peer_update" => Self::PeerUpdate,
|
||||
"peer_delete" => Self::PeerDelete,
|
||||
"peer_enable" => Self::PeerEnable,
|
||||
"peer_disable" => Self::PeerDisable,
|
||||
"peer_revoke" => Self::PeerRevoke,
|
||||
"peer_expire" => Self::PeerExpire,
|
||||
"peer_rotate_keys" => Self::PeerRotateKeys,
|
||||
"network_create" => Self::NetworkCreate,
|
||||
"network_update" => Self::NetworkUpdate,
|
||||
"network_delete" => Self::NetworkDelete,
|
||||
"route_create" => Self::RouteCreate,
|
||||
"route_update" => Self::RouteUpdate,
|
||||
"route_delete" => Self::RouteDelete,
|
||||
"route_enable" => Self::RouteEnable,
|
||||
"route_disable" => Self::RouteDisable,
|
||||
"firewall_create" => Self::FirewallCreate,
|
||||
"firewall_update" => Self::FirewallUpdate,
|
||||
"firewall_delete" => Self::FirewallDelete,
|
||||
"backup_create" => Self::BackupCreate,
|
||||
"backup_restore" => Self::BackupRestore,
|
||||
"backup_delete" => Self::BackupDelete,
|
||||
"session_invalidated" => Self::SessionInvalidated,
|
||||
"settings_update" => Self::SettingsUpdate,
|
||||
"reconciliation_run" => Self::ReconciliationRun,
|
||||
"reconciliation_failed" => Self::ReconciliationFailed,
|
||||
"import_completed" => Self::ImportCompleted,
|
||||
_ => {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid AuditEventType: {}",
|
||||
s
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub id: i64,
|
||||
pub event_type: AuditEventType,
|
||||
pub actor: String,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub metadata: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_audit_event_type_roundtrip() {
|
||||
assert_eq!(
|
||||
AuditEventType::from_str("login").unwrap(),
|
||||
AuditEventType::Login
|
||||
);
|
||||
assert_eq!(AuditEventType::Login.to_string(), "login");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Authentication types.
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Admin {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub totp_secret: Option<String>,
|
||||
pub totp_enabled: bool,
|
||||
pub last_login_at: Option<NaiveDateTime>,
|
||||
pub last_login_ip: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Admin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Admin")
|
||||
.field("id", &self.id)
|
||||
.field("username", &self.username)
|
||||
.field("password_hash", &"[REDACTED]")
|
||||
.field(
|
||||
"totp_secret",
|
||||
&self.totp_secret.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("totp_enabled", &self.totp_enabled)
|
||||
.field("last_login_at", &self.last_login_at)
|
||||
.field("last_login_ip", &self.last_login_ip)
|
||||
.field("created_at", &self.created_at)
|
||||
.field("updated_at", &self.updated_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub admin_id: i64,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub expires_at: NaiveDateTime,
|
||||
pub last_seen_at: Option<NaiveDateTime>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ApiToken {
|
||||
pub id: String,
|
||||
pub admin_id: i64,
|
||||
pub name: String,
|
||||
pub token_hash: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub expires_at: Option<NaiveDateTime>,
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ApiToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ApiToken")
|
||||
.field("id", &self.id)
|
||||
.field("admin_id", &self.admin_id)
|
||||
.field("name", &self.name)
|
||||
.field("token_hash", &"[REDACTED]")
|
||||
.field("created_at", &self.created_at)
|
||||
.field("expires_at", &self.expires_at)
|
||||
.field("last_used_at", &self.last_used_at)
|
||||
.field("revoked_at", &self.revoked_at)
|
||||
.field("revoked", &self.revoked)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginAttempt {
|
||||
pub id: i64,
|
||||
pub ip_address: String,
|
||||
pub attempted_at: NaiveDateTime,
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Backup types.
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupManifest {
|
||||
pub version: String,
|
||||
pub schema_version: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub checksum: String,
|
||||
pub encrypted: bool,
|
||||
pub files: Vec<BackupFileEntry>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupFileEntry {
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
pub checksum: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupMeta {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub schema_version: String,
|
||||
pub encrypted: bool,
|
||||
pub description: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Client environment and MTU profile domain models.
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Connection environment type for client profiles.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionType {
|
||||
Web,
|
||||
Mobile,
|
||||
Wifi,
|
||||
Wired,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ConnectionType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Web => "web",
|
||||
Self::Mobile => "mobile",
|
||||
Self::Wifi => "wifi",
|
||||
Self::Wired => "wired",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ConnectionType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ConnectionType {
|
||||
type Err = crate::error::Nx9Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"web" => Ok(Self::Web),
|
||||
"mobile" | "cellular" | "lte" | "5g" => Ok(Self::Mobile),
|
||||
"wifi" | "wi-fi" | "wireless" => Ok(Self::Wifi),
|
||||
"wired" | "ethernet" | "lan" => Ok(Self::Wired),
|
||||
"other" | "unknown" => Ok(Self::Other),
|
||||
_ => Err(crate::error::Nx9Error::Validation(format!(
|
||||
"invalid connection type '{s}', expected: web, mobile, wifi, wired, other"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Network Address Translation (NAT) conditions for client profiles.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NatType {
|
||||
Direct,
|
||||
Cgnat,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl NatType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Direct => "direct",
|
||||
Self::Cgnat => "cgnat",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for NatType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for NatType {
|
||||
type Err = crate::error::Nx9Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"direct" | "public" | "open" => Ok(Self::Direct),
|
||||
"cgnat" | "nat444" | "carrier_grade_nat" => Ok(Self::Cgnat),
|
||||
"unknown" | "n/a" | "none" => Ok(Self::Unknown),
|
||||
_ => Err(crate::error::Nx9Error::Validation(format!(
|
||||
"invalid nat type '{s}', expected: direct, cgnat, unknown"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client device platform categories.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeviceCategory {
|
||||
Android,
|
||||
Ios,
|
||||
Linux,
|
||||
Windows,
|
||||
Macos,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl DeviceCategory {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Android => "android",
|
||||
Self::Ios => "ios",
|
||||
Self::Linux => "linux",
|
||||
Self::Windows => "windows",
|
||||
Self::Macos => "macos",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DeviceCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DeviceCategory {
|
||||
type Err = crate::error::Nx9Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"android" => Ok(Self::Android),
|
||||
"ios" | "iphone" | "ipad" => Ok(Self::Ios),
|
||||
"linux" | "unix" => Ok(Self::Linux),
|
||||
"windows" | "win" => Ok(Self::Windows),
|
||||
"macos" | "mac" | "darwin" | "osx" => Ok(Self::Macos),
|
||||
"other" | "embedded" | "router" => Ok(Self::Other),
|
||||
_ => Err(crate::error::Nx9Error::Validation(format!(
|
||||
"invalid device category '{s}', expected: android, ios, linux, windows, macos, other"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authoritative client profile definition.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ClientProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: Option<String>,
|
||||
pub device: Option<DeviceCategory>,
|
||||
pub connection_type: ConnectionType,
|
||||
pub nat_type: NatType,
|
||||
pub mtu: u16,
|
||||
pub dns: Option<String>,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
pub is_builtin: bool,
|
||||
pub description: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
/// Dynamically resolved client profile for configuration export.
|
||||
///
|
||||
/// NOTE: Endpoint and AllowedIPs belong to the authoritative WireGuard peer
|
||||
/// configuration and MUST NOT be silently overridden by a generic client environment profile.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedClientProfile {
|
||||
pub mtu: u16,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
pub dns: Option<String>,
|
||||
pub is_manually_overridden: bool,
|
||||
pub applied_profile_id: String,
|
||||
pub applied_profile_name: String,
|
||||
pub connection_type: ConnectionType,
|
||||
pub nat_type: NatType,
|
||||
pub device: Option<DeviceCategory>,
|
||||
pub provider: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connection_type_roundtrip() {
|
||||
let types = [
|
||||
(ConnectionType::Web, "web"),
|
||||
(ConnectionType::Mobile, "mobile"),
|
||||
(ConnectionType::Wifi, "wifi"),
|
||||
(ConnectionType::Wired, "wired"),
|
||||
(ConnectionType::Other, "other"),
|
||||
];
|
||||
|
||||
for (variant, name) in types {
|
||||
assert_eq!(variant.as_str(), name);
|
||||
assert_eq!(variant.to_string(), name);
|
||||
assert_eq!(ConnectionType::from_str(name).unwrap(), variant);
|
||||
}
|
||||
|
||||
assert!(ConnectionType::from_str("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nat_type_roundtrip() {
|
||||
let types = [
|
||||
(NatType::Direct, "direct"),
|
||||
(NatType::Cgnat, "cgnat"),
|
||||
(NatType::Unknown, "unknown"),
|
||||
];
|
||||
|
||||
for (variant, name) in types {
|
||||
assert_eq!(variant.as_str(), name);
|
||||
assert_eq!(variant.to_string(), name);
|
||||
assert_eq!(NatType::from_str(name).unwrap(), variant);
|
||||
}
|
||||
|
||||
assert!(NatType::from_str("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_category_roundtrip() {
|
||||
let categories = [
|
||||
(DeviceCategory::Android, "android"),
|
||||
(DeviceCategory::Ios, "ios"),
|
||||
(DeviceCategory::Linux, "linux"),
|
||||
(DeviceCategory::Windows, "windows"),
|
||||
(DeviceCategory::Macos, "macos"),
|
||||
(DeviceCategory::Other, "other"),
|
||||
];
|
||||
|
||||
for (variant, name) in categories {
|
||||
assert_eq!(variant.as_str(), name);
|
||||
assert_eq!(variant.to_string(), name);
|
||||
assert_eq!(DeviceCategory::from_str(name).unwrap(), variant);
|
||||
}
|
||||
|
||||
assert!(DeviceCategory::from_str("invalid").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Diagnostic types and models.
|
||||
|
||||
use crate::error::Nx9Error;
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Evaluation status for an individual diagnostic check or report.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticStatus {
|
||||
Pass,
|
||||
Warning,
|
||||
Fail,
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
impl DiagnosticStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pass => "pass",
|
||||
Self::Warning => "warning",
|
||||
Self::Fail => "fail",
|
||||
Self::NotApplicable => "not_applicable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DiagnosticStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DiagnosticStatus {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"pass" | "ok" => Ok(Self::Pass),
|
||||
"warning" | "warn" => Ok(Self::Warning),
|
||||
"fail" | "failed" | "error" => Ok(Self::Fail),
|
||||
"not_applicable" | "n/a" | "na" => Ok(Self::NotApplicable),
|
||||
_ => Err(Nx9Error::Validation(format!(
|
||||
"invalid DiagnosticStatus: {s}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Target subsystem for diagnostics.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticSubsystem {
|
||||
System,
|
||||
Network,
|
||||
Wan,
|
||||
Wireguard,
|
||||
Peer,
|
||||
Routing,
|
||||
Forwarding,
|
||||
Firewall,
|
||||
Nat,
|
||||
Mtu,
|
||||
Reconciliation,
|
||||
All,
|
||||
}
|
||||
|
||||
impl DiagnosticSubsystem {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Network => "network",
|
||||
Self::Wan => "wan",
|
||||
Self::Wireguard => "wireguard",
|
||||
Self::Peer => "peer",
|
||||
Self::Routing => "routing",
|
||||
Self::Forwarding => "forwarding",
|
||||
Self::Firewall => "firewall",
|
||||
Self::Nat => "nat",
|
||||
Self::Mtu => "mtu",
|
||||
Self::Reconciliation => "reconciliation",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DiagnosticSubsystem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DiagnosticSubsystem {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"system" => Ok(Self::System),
|
||||
"network" => Ok(Self::Network),
|
||||
"wan" => Ok(Self::Wan),
|
||||
"wireguard" | "wg" => Ok(Self::Wireguard),
|
||||
"peer" => Ok(Self::Peer),
|
||||
"routing" | "routes" | "route" => Ok(Self::Routing),
|
||||
"forwarding" | "fwd" => Ok(Self::Forwarding),
|
||||
"firewall" | "fw" | "nft" | "nftables" => Ok(Self::Firewall),
|
||||
"nat" => Ok(Self::Nat),
|
||||
"mtu" => Ok(Self::Mtu),
|
||||
"reconciliation" | "reconcile" => Ok(Self::Reconciliation),
|
||||
"all" => Ok(Self::All),
|
||||
_ => Err(Nx9Error::Validation(format!(
|
||||
"invalid DiagnosticSubsystem: {s}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An individual diagnostic check observation and result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiagnosticCheck {
|
||||
pub check_name: String,
|
||||
pub status: DiagnosticStatus,
|
||||
pub observed_value: String,
|
||||
pub expected_value: Option<String>,
|
||||
pub diagnostic_message: String,
|
||||
pub remediation_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Aggregated report for a diagnostic subsystem evaluation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiagnosticReport {
|
||||
pub subsystem: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub overall_status: DiagnosticStatus,
|
||||
pub checks: Vec<DiagnosticCheck>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_diagnostic_status_roundtrip() {
|
||||
assert_eq!(
|
||||
DiagnosticStatus::from_str("pass").unwrap(),
|
||||
DiagnosticStatus::Pass
|
||||
);
|
||||
assert_eq!(DiagnosticStatus::Pass.to_string(), "pass");
|
||||
assert_eq!(
|
||||
DiagnosticStatus::from_str("warning").unwrap(),
|
||||
DiagnosticStatus::Warning
|
||||
);
|
||||
assert_eq!(
|
||||
DiagnosticStatus::from_str("fail").unwrap(),
|
||||
DiagnosticStatus::Fail
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Firewall types.
|
||||
|
||||
use crate::error::Nx9Error;
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FirewallAction {
|
||||
Accept,
|
||||
Drop,
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl FirewallAction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Accept => "accept",
|
||||
Self::Drop => "drop",
|
||||
Self::Reject => "reject",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for FirewallAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
impl FromStr for FirewallAction {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"accept" => Ok(Self::Accept),
|
||||
"drop" => Ok(Self::Drop),
|
||||
"reject" => Ok(Self::Reject),
|
||||
_ => Err(Nx9Error::Validation(format!(
|
||||
"invalid FirewallAction: {}",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FirewallProtocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
TcpUdp,
|
||||
Icmp,
|
||||
Any,
|
||||
}
|
||||
|
||||
impl FirewallProtocol {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Tcp => "tcp",
|
||||
Self::Udp => "udp",
|
||||
Self::TcpUdp => "tcp_udp",
|
||||
Self::Icmp => "icmp",
|
||||
Self::Any => "any",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for FirewallProtocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
impl FromStr for FirewallProtocol {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"tcp" => Ok(Self::Tcp),
|
||||
"udp" => Ok(Self::Udp),
|
||||
"tcp_udp" | "tcp/udp" => Ok(Self::TcpUdp),
|
||||
"icmp" => Ok(Self::Icmp),
|
||||
"any" => Ok(Self::Any),
|
||||
_ => Err(Nx9Error::Validation(format!(
|
||||
"invalid FirewallProtocol: {}",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FirewallDirection {
|
||||
In,
|
||||
Out,
|
||||
Forward,
|
||||
}
|
||||
|
||||
impl FirewallDirection {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::In => "in",
|
||||
Self::Out => "out",
|
||||
Self::Forward => "forward",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for FirewallDirection {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
impl FromStr for FirewallDirection {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"in" => Ok(Self::In),
|
||||
"out" => Ok(Self::Out),
|
||||
"forward" => Ok(Self::Forward),
|
||||
_ => Err(Nx9Error::Validation(format!(
|
||||
"invalid FirewallDirection: {}",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FirewallRule {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub interface_id: Option<Uuid>,
|
||||
pub peer_id: Option<Uuid>,
|
||||
pub direction: FirewallDirection,
|
||||
pub action: FirewallAction,
|
||||
pub protocol: FirewallProtocol,
|
||||
pub source: Option<String>,
|
||||
pub destination: Option<String>,
|
||||
pub source_port: Option<u16>,
|
||||
pub destination_port: Option<u16>,
|
||||
pub port_range: Option<String>,
|
||||
pub priority: i32,
|
||||
pub enabled: bool,
|
||||
pub description: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Domain types.
|
||||
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_profile;
|
||||
pub mod diagnostics;
|
||||
pub mod firewall;
|
||||
pub mod network;
|
||||
pub mod settings;
|
||||
pub mod wireguard;
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Network types.
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use ipnet::IpNet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Network {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub cidr: IpNet,
|
||||
pub enabled: bool,
|
||||
pub description: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Route {
|
||||
pub id: Uuid,
|
||||
pub network_id: Option<Uuid>,
|
||||
pub interface_id: Option<Uuid>,
|
||||
pub destination: IpNet,
|
||||
pub gateway: Option<IpAddr>,
|
||||
pub interface_name: Option<String>,
|
||||
pub metric: Option<u32>,
|
||||
pub enabled: bool,
|
||||
pub description: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Settings domain types.
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// System setting key-value pair.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Setting {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub is_secret: bool,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Setting {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Setting")
|
||||
.field("key", &self.key)
|
||||
.field(
|
||||
"value",
|
||||
if self.is_secret {
|
||||
&"[REDACTED]" as &dyn std::fmt::Debug
|
||||
} else {
|
||||
&self.value as &dyn std::fmt::Debug
|
||||
},
|
||||
)
|
||||
.field("is_secret", &self.is_secret)
|
||||
.field("updated_at", &self.updated_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! WireGuard types.
|
||||
|
||||
use crate::error::Nx9Error;
|
||||
use chrono::NaiveDateTime;
|
||||
use ipnet::IpNet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WireGuardPrivateKey(String);
|
||||
|
||||
impl std::fmt::Debug for WireGuardPrivateKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
impl WireGuardPrivateKey {
|
||||
pub fn new(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, std::hash::Hash, Serialize, Deserialize)]
|
||||
pub struct WireGuardPublicKey(String);
|
||||
|
||||
impl WireGuardPublicKey {
|
||||
pub fn new(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for WireGuardPublicKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WireGuardPresharedKey(String);
|
||||
|
||||
impl std::fmt::Debug for WireGuardPresharedKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
impl WireGuardPresharedKey {
|
||||
pub fn new(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PeerType {
|
||||
RoadWarrior,
|
||||
SiteGateway,
|
||||
Server,
|
||||
Relay,
|
||||
}
|
||||
|
||||
impl PeerType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::RoadWarrior => "road_warrior",
|
||||
Self::SiteGateway => "site_gateway",
|
||||
Self::Server => "server",
|
||||
Self::Relay => "relay",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for PeerType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
impl FromStr for PeerType {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"road_warrior" => Ok(Self::RoadWarrior),
|
||||
"site_gateway" => Ok(Self::SiteGateway),
|
||||
"server" => Ok(Self::Server),
|
||||
"relay" => Ok(Self::Relay),
|
||||
_ => Err(Nx9Error::Validation(format!("invalid PeerType: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PeerState {
|
||||
Active,
|
||||
Disabled,
|
||||
Revoked,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl PeerState {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Disabled => "disabled",
|
||||
Self::Revoked => "revoked",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for PeerState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
impl FromStr for PeerState {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"active" => Ok(Self::Active),
|
||||
"disabled" => Ok(Self::Disabled),
|
||||
"revoked" => Ok(Self::Revoked),
|
||||
"expired" => Ok(Self::Expired),
|
||||
_ => Err(Nx9Error::Validation(format!("invalid PeerState: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PeerProfile {
|
||||
FullTunnel,
|
||||
SplitTunnel,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl PeerProfile {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FullTunnel => "full_tunnel",
|
||||
Self::SplitTunnel => "split_tunnel",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for PeerProfile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
impl FromStr for PeerProfile {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"full_tunnel" => Ok(Self::FullTunnel),
|
||||
"split_tunnel" => Ok(Self::SplitTunnel),
|
||||
"custom" => Ok(Self::Custom),
|
||||
_ => Err(Nx9Error::Validation(format!("invalid PeerProfile: {}", s))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Interface {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub private_key: WireGuardPrivateKey,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
pub listen_port: u16,
|
||||
pub address_v4: IpNet,
|
||||
pub address_v6: Option<IpNet>,
|
||||
pub mtu: Option<u16>,
|
||||
pub dns: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub pre_up: Option<String>,
|
||||
pub post_up: Option<String>,
|
||||
pub pre_down: Option<String>,
|
||||
pub post_down: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Peer {
|
||||
pub id: Uuid,
|
||||
pub interface_id: Uuid,
|
||||
pub name: String,
|
||||
pub peer_type: PeerType,
|
||||
pub state: PeerState,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
pub private_key: Option<WireGuardPrivateKey>,
|
||||
pub preshared_key: Option<WireGuardPresharedKey>,
|
||||
pub endpoint: Option<String>,
|
||||
pub allowed_ips: String,
|
||||
pub server_allowed_ips: Option<String>,
|
||||
pub address_v4: Option<IpNet>,
|
||||
pub address_v6: Option<IpNet>,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
pub profile: PeerProfile,
|
||||
pub expires_at: Option<NaiveDateTime>,
|
||||
pub last_handshake_at: Option<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_peer_type_roundtrip() {
|
||||
assert_eq!(
|
||||
PeerType::from_str("road_warrior").unwrap(),
|
||||
PeerType::RoadWarrior
|
||||
);
|
||||
assert_eq!(PeerType::RoadWarrior.to_string(), "road_warrior");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_key_debug() {
|
||||
let pk = WireGuardPrivateKey::new("secret".to_string());
|
||||
assert_eq!(format!("{:?}", pk), "[REDACTED]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Validation utilities.
|
||||
|
||||
use crate::error::{Nx9Error, Result};
|
||||
use ipnet::IpNet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Parsed and validated firewall port specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortSpec {
|
||||
Single(u16),
|
||||
Range(u16, u16),
|
||||
List(Vec<u16>),
|
||||
}
|
||||
|
||||
impl Display for PortSpec {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Single(p) => write!(f, "{p}"),
|
||||
Self::Range(start, end) => write!(f, "{start}-{end}"),
|
||||
Self::List(ports) => {
|
||||
let s: Vec<String> = ports.iter().map(|p| p.to_string()).collect();
|
||||
write!(f, "{}", s.join(","))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and parse a port specification (single "443", range "8000-8100", or list "53,80,443").
|
||||
pub fn validate_port_spec(spec: &str) -> Result<PortSpec> {
|
||||
let trimmed = spec.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Nx9Error::Validation(
|
||||
"port specification cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if trimmed.contains('-') {
|
||||
let parts: Vec<&str> = trimmed.split('-').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid port range format '{spec}'"
|
||||
)));
|
||||
}
|
||||
let start_str = parts[0].trim();
|
||||
let end_str = parts[1].trim();
|
||||
if start_str.is_empty() || end_str.is_empty() {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid port range format '{spec}'"
|
||||
)));
|
||||
}
|
||||
let start: u16 = start_str
|
||||
.parse()
|
||||
.map_err(|_| Nx9Error::Validation(format!("invalid start port in range '{spec}'")))?;
|
||||
let end: u16 = end_str
|
||||
.parse()
|
||||
.map_err(|_| Nx9Error::Validation(format!("invalid end port in range '{spec}'")))?;
|
||||
|
||||
if start == 0 || end == 0 {
|
||||
return Err(Nx9Error::Validation("ports must be non-zero".into()));
|
||||
}
|
||||
if start > end {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"port range start ({start}) cannot exceed end ({end})"
|
||||
)));
|
||||
}
|
||||
if start == end {
|
||||
return Ok(PortSpec::Single(start));
|
||||
}
|
||||
return Ok(PortSpec::Range(start, end));
|
||||
}
|
||||
|
||||
if trimmed.contains(',') {
|
||||
let mut ports = Vec::new();
|
||||
for p_str in trimmed.split(',') {
|
||||
let p_trim = p_str.trim();
|
||||
if p_trim.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let p: u16 = p_trim
|
||||
.parse()
|
||||
.map_err(|_| Nx9Error::Validation(format!("invalid port number '{p_trim}'")))?;
|
||||
if p == 0 {
|
||||
return Err(Nx9Error::Validation("ports must be non-zero".into()));
|
||||
}
|
||||
if !ports.contains(&p) {
|
||||
ports.push(p);
|
||||
}
|
||||
}
|
||||
if ports.is_empty() {
|
||||
return Err(Nx9Error::Validation(
|
||||
"port list must contain at least one valid port".into(),
|
||||
));
|
||||
}
|
||||
ports.sort_unstable();
|
||||
if ports.len() == 1 {
|
||||
return Ok(PortSpec::Single(ports[0]));
|
||||
}
|
||||
return Ok(PortSpec::List(ports));
|
||||
}
|
||||
|
||||
let p: u16 = trimmed
|
||||
.parse()
|
||||
.map_err(|_| Nx9Error::Validation(format!("invalid port number '{spec}'")))?;
|
||||
if p == 0 {
|
||||
return Err(Nx9Error::Validation("port must be non-zero".into()));
|
||||
}
|
||||
Ok(PortSpec::Single(p))
|
||||
}
|
||||
|
||||
/// Validate CIDR.
|
||||
pub fn validate_cidr(cidr: &str) -> Result<IpNet> {
|
||||
IpNet::from_str(cidr)
|
||||
.map_err(|e| Nx9Error::Validation(format!("invalid CIDR '{}': {}", cidr, e)))
|
||||
}
|
||||
|
||||
/// Validate IP.
|
||||
pub fn validate_ip(ip: &str) -> Result<IpAddr> {
|
||||
IpAddr::from_str(ip)
|
||||
.map_err(|e| Nx9Error::Validation(format!("invalid IP address '{}': {}", ip, e)))
|
||||
}
|
||||
|
||||
/// Validate that an IP address belongs to a specified subnet network and matches address family.
|
||||
pub fn validate_ip_in_network(ip: IpAddr, net: IpNet) -> Result<()> {
|
||||
match (ip, net) {
|
||||
(IpAddr::V4(_), IpNet::V6(_)) => {
|
||||
return Err(Nx9Error::Validation(
|
||||
"address family mismatch: IPv4 address against IPv6 network".into(),
|
||||
));
|
||||
}
|
||||
(IpAddr::V6(_), IpNet::V4(_)) => {
|
||||
return Err(Nx9Error::Validation(
|
||||
"address family mismatch: IPv6 address against IPv4 network".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !net.contains(&ip) {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"IP address '{ip}' is outside network CIDR '{net}'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate port.
|
||||
pub fn validate_port(port: u16) -> Result<u16> {
|
||||
if port == 0 {
|
||||
return Err(Nx9Error::Validation("port must be non-zero".into()));
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Validate interface name.
|
||||
pub fn validate_interface_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
return Err(Nx9Error::Validation(
|
||||
"interface name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if name.len() > 15 {
|
||||
return Err(Nx9Error::Validation("interface name max 15 chars".into()));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(Nx9Error::Validation(
|
||||
"interface name contains invalid characters".into(),
|
||||
));
|
||||
}
|
||||
if !name.chars().next().unwrap().is_ascii_alphabetic() {
|
||||
return Err(Nx9Error::Validation(
|
||||
"interface name must start with letter".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate peer name.
|
||||
pub fn validate_peer_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
return Err(Nx9Error::Validation("peer name cannot be empty".into()));
|
||||
}
|
||||
if name.len() > 64 {
|
||||
return Err(Nx9Error::Validation("peer name max 64 chars".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
pub const MAX_PASSWORD_LENGTH: usize = 256;
|
||||
|
||||
/// Validate password strength.
|
||||
pub fn validate_password_strength(password: &str) -> Result<()> {
|
||||
if password.len() < MIN_PASSWORD_LENGTH {
|
||||
return Err(Nx9Error::Validation("password too short".into()));
|
||||
}
|
||||
if password.len() > MAX_PASSWORD_LENGTH {
|
||||
return Err(Nx9Error::Validation("password too long".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate listen port.
|
||||
pub fn validate_listen_port(port: u16) -> Result<u16> {
|
||||
if port < 1024 {
|
||||
return Err(Nx9Error::Validation("cannot use privileged port".into()));
|
||||
}
|
||||
validate_port(port)
|
||||
}
|
||||
|
||||
/// Validate MTU.
|
||||
pub fn validate_mtu(mtu: u16) -> Result<u16> {
|
||||
if mtu < 1280 {
|
||||
return Err(Nx9Error::Validation(
|
||||
"MTU too small, minimum is 1280".into(),
|
||||
));
|
||||
}
|
||||
if mtu > 9000 {
|
||||
return Err(Nx9Error::Validation(
|
||||
"MTU too large, maximum is 9000".into(),
|
||||
));
|
||||
}
|
||||
Ok(mtu)
|
||||
}
|
||||
|
||||
/// Validate client MTU.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Normal range: 1280 to 1500
|
||||
/// - Jumbo frames: up to 9000 (allowed without silent clamping)
|
||||
/// - Invalid: < 1280 or > 9000 (rejected with error)
|
||||
pub fn validate_client_mtu(mtu: u16) -> Result<u16> {
|
||||
if mtu < 1280 {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"client MTU {mtu} is below IPv6 minimum MTU (1280)"
|
||||
)));
|
||||
}
|
||||
if mtu > 9000 {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"client MTU {mtu} exceeds maximum allowable jumbo frame MTU (9000)"
|
||||
)));
|
||||
}
|
||||
Ok(mtu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_cidr() {
|
||||
assert!(validate_cidr("192.168.1.0/24").is_ok());
|
||||
assert!(validate_cidr("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_ip() {
|
||||
assert!(validate_ip("10.0.0.1").is_ok());
|
||||
assert!(validate_ip("256.0.0.1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_port_spec() {
|
||||
assert_eq!(validate_port_spec("443").unwrap(), PortSpec::Single(443));
|
||||
assert_eq!(
|
||||
validate_port_spec("8000-8100").unwrap(),
|
||||
PortSpec::Range(8000, 8100)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_port_spec("53,80,443").unwrap(),
|
||||
PortSpec::List(vec![53, 80, 443])
|
||||
);
|
||||
assert!(validate_port_spec("8100-8000").is_err());
|
||||
assert!(validate_port_spec("0").is_err());
|
||||
assert!(validate_port_spec("70000").is_err());
|
||||
assert!(validate_port_spec("").is_err());
|
||||
assert!(validate_port_spec("abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_ip_in_network() {
|
||||
let net_v4: IpNet = "10.0.0.0/24".parse().unwrap();
|
||||
assert!(validate_ip_in_network("10.0.0.5".parse().unwrap(), net_v4).is_ok());
|
||||
assert!(validate_ip_in_network("192.168.1.1".parse().unwrap(), net_v4).is_err());
|
||||
|
||||
let net_v6: IpNet = "fd00::/64".parse().unwrap();
|
||||
assert!(validate_ip_in_network("fd00::1".parse().unwrap(), net_v6).is_ok());
|
||||
assert!(validate_ip_in_network("10.0.0.1".parse().unwrap(), net_v6).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_port() {
|
||||
assert!(validate_port(8080).is_ok());
|
||||
assert!(validate_port(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_interface_name() {
|
||||
assert!(validate_interface_name("wg0").is_ok());
|
||||
assert!(validate_interface_name("0wg").is_err());
|
||||
assert!(validate_interface_name("verylonginterfacenamehere").is_err());
|
||||
assert!(validate_interface_name("wg@0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_strength() {
|
||||
assert!(validate_password_strength("strongpassword").is_ok());
|
||||
assert!(validate_password_strength("short").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_client_mtu() {
|
||||
// Below minimum
|
||||
assert!(validate_client_mtu(1279).is_err());
|
||||
assert!(validate_client_mtu(576).is_err());
|
||||
assert!(validate_client_mtu(0).is_err());
|
||||
|
||||
// Normal values
|
||||
assert_eq!(validate_client_mtu(1280).unwrap(), 1280);
|
||||
assert_eq!(validate_client_mtu(1360).unwrap(), 1360);
|
||||
assert_eq!(validate_client_mtu(1420).unwrap(), 1420);
|
||||
assert_eq!(validate_client_mtu(1500).unwrap(), 1500);
|
||||
|
||||
// Jumbo frame values
|
||||
assert_eq!(validate_client_mtu(1501).unwrap(), 1501);
|
||||
assert_eq!(validate_client_mtu(9000).unwrap(), 9000);
|
||||
|
||||
// Above maximum
|
||||
assert!(validate_client_mtu(9001).is_err());
|
||||
assert!(validate_client_mtu(65535).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Automated validation suite for environment variable namespace and precedence.
|
||||
|
||||
use nx9_wg_core::config::{AppConfig, BootstrapConfig};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn test_nx9_wg_env_variable_precedence_and_overrides() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
// Set canonical NX9_WG_ environment variables
|
||||
unsafe {
|
||||
std::env::set_var("NX9_WG_CONFIG", "/custom/etc/config.toml");
|
||||
std::env::set_var("NX9_WG_DATA_DIR", "/custom/var/data");
|
||||
std::env::set_var("NX9_WG_LISTEN_ADDR", "127.0.0.1:9090");
|
||||
std::env::set_var("NX9_WG_LOG_LEVEL", "warn");
|
||||
std::env::set_var("NX9_WG_SESSION_TIMEOUT", "72");
|
||||
std::env::set_var("NX9_WG_RECONCILIATION_INTERVAL", "15");
|
||||
std::env::set_var("NX9_WG_BACKUP_DIR", "/custom/backups");
|
||||
std::env::set_var("NX9_WG_BACKUP_MAX_COUNT", "20");
|
||||
std::env::set_var("NX9_WG_BACKUP_SCHEDULE", "0 3 * * *");
|
||||
std::env::set_var("NX9_WG_ADMIN_USERNAME", "superadmin");
|
||||
std::env::set_var("NX9_WG_ADMIN_PASSWORD", "SuperSecretPW987!");
|
||||
}
|
||||
|
||||
config.apply_env_overrides().expect("apply env overrides");
|
||||
|
||||
assert_eq!(config.config_file, PathBuf::from("/custom/etc/config.toml"));
|
||||
assert_eq!(config.data_dir, PathBuf::from("/custom/var/data"));
|
||||
assert_eq!(config.bind_address, "127.0.0.1:9090".parse().unwrap());
|
||||
assert_eq!(config.log_level, "warn");
|
||||
assert_eq!(config.session_expiry_hours, 72);
|
||||
assert_eq!(config.reconciliation_interval_secs, 15);
|
||||
assert_eq!(config.backup.dir, PathBuf::from("/custom/backups"));
|
||||
assert_eq!(config.backup.max_count, 20);
|
||||
assert_eq!(config.backup.schedule, Some("0 3 * * *".to_string()));
|
||||
|
||||
let boot = config.bootstrap.expect("bootstrap should be present");
|
||||
assert_eq!(boot.admin_username, Some("superadmin".to_string()));
|
||||
assert_eq!(boot.admin_password, Some("SuperSecretPW987!".to_string()));
|
||||
|
||||
// Clean up
|
||||
unsafe {
|
||||
std::env::remove_var("NX9_WG_CONFIG");
|
||||
std::env::remove_var("NX9_WG_DATA_DIR");
|
||||
std::env::remove_var("NX9_WG_LISTEN_ADDR");
|
||||
std::env::remove_var("NX9_WG_LOG_LEVEL");
|
||||
std::env::remove_var("NX9_WG_SESSION_TIMEOUT");
|
||||
std::env::remove_var("NX9_WG_RECONCILIATION_INTERVAL");
|
||||
std::env::remove_var("NX9_WG_BACKUP_DIR");
|
||||
std::env::remove_var("NX9_WG_BACKUP_MAX_COUNT");
|
||||
std::env::remove_var("NX9_WG_BACKUP_SCHEDULE");
|
||||
std::env::remove_var("NX9_WG_ADMIN_USERNAME");
|
||||
std::env::remove_var("NX9_WG_ADMIN_PASSWORD");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_env_variable_values() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
let mut config = AppConfig::default();
|
||||
unsafe {
|
||||
std::env::set_var("NX9_WG_LISTEN_ADDR", "invalid-ip-and-port");
|
||||
}
|
||||
assert!(config.apply_env_overrides().is_err());
|
||||
unsafe {
|
||||
std::env::remove_var("NX9_WG_LISTEN_ADDR");
|
||||
}
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("NX9_WG_SESSION_TIMEOUT", "not-a-number");
|
||||
}
|
||||
assert!(config.apply_env_overrides().is_err());
|
||||
unsafe {
|
||||
std::env::remove_var("NX9_WG_SESSION_TIMEOUT");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_redaction() {
|
||||
let boot = BootstrapConfig {
|
||||
admin_username: Some("admin".to_string()),
|
||||
admin_password: Some("secret12345".to_string()),
|
||||
};
|
||||
let formatted = format!("{boot:?}");
|
||||
assert!(!formatted.contains("secret12345"));
|
||||
assert!(formatted.contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_database_path_resolution() {
|
||||
// Default database path should be data_dir/nx9-wg.db
|
||||
let config = AppConfig::default();
|
||||
let expected_db = config.data_dir.join("nx9-wg.db");
|
||||
assert_eq!(expected_db, PathBuf::from("/var/lib/nx9-wg/nx9-wg.db"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explicit_database_dir_override() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
let config = AppConfig::default();
|
||||
// When --database is explicitly provided, it should be used directly
|
||||
// The test verifies the default path is what we expect
|
||||
assert_eq!(config.data_dir, PathBuf::from("/var/lib/nx9-wg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backup_directory_consistency() {
|
||||
let config = AppConfig::default();
|
||||
// Backup directory should always be consistent: /var/lib/nx9-wg/backups
|
||||
assert_eq!(config.backup.dir, PathBuf::from("/var/lib/nx9-wg/backups"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nx9_wg_database_env_var() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
// NX9_WG_DATABASE should be honored via CLI args
|
||||
// This test documents that the env var is in the canonical namespace
|
||||
let cli_args = &["--database", "/custom/path/test.db"];
|
||||
// We're verifying this is the correct pattern to use
|
||||
assert_eq!(cli_args[0], "--database");
|
||||
assert_eq!(cli_args[1], "/custom/path/test.db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_env_namespace_only() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
// Verify only NX9_WG_* variables are used
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
// Try setting a non-canonical variable - should be ignored
|
||||
unsafe {
|
||||
std::env::set_var("RUST_LOG", "debug");
|
||||
std::env::set_var("NX9_LOG_LEVEL", "error");
|
||||
}
|
||||
|
||||
config.apply_env_overrides().expect("apply env overrides");
|
||||
|
||||
// These non-canonical variables should be ignored
|
||||
assert_eq!(config.log_level, "info"); // Should remain default
|
||||
|
||||
// Clean up
|
||||
unsafe {
|
||||
std::env::remove_var("RUST_LOG");
|
||||
std::env::remove_var("NX9_LOG_LEVEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_bind_address_is_localhost() {
|
||||
let config = AppConfig::default();
|
||||
// Verify bind address default
|
||||
assert_eq!(config.bind_address, "127.0.0.1:8080".parse().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_reconciliation_interval() {
|
||||
let config = AppConfig::default();
|
||||
// Default reconciliation interval should be 60 seconds
|
||||
assert_eq!(config.reconciliation_interval_secs, 60);
|
||||
}
|
||||
Reference in new issue
Block a user