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:
thakaresandCopilot committed 2026-08-16 16:26:24 +05:30
commit 2ac6c81dfe
140 files changed
+31342

No files matched your search

+23
View File
@@ -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
+227
View File
@@ -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");
}
}
}
+123
View File
@@ -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);
}
}
+37
View File
@@ -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>;
+7
View File
@@ -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;
+187
View File
@@ -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");
}
}
+84
View File
@@ -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,
}
+35
View File
@@ -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());
}
}
+157
View File
@@ -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
);
}
}
+146
View File
@@ -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,
}
+11
View File
@@ -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;
+33
View File
@@ -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,
}
+31
View File
@@ -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()
}
}
+236
View File
@@ -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]");
}
}
+337
View File
@@ -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);
}