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

+28
View File
@@ -0,0 +1,28 @@
[package]
name = "nx9-wg-api"
description = "Axum REST API, application services, and WebSocket server for nx9-wg"
version.workspace = true
edition.workspace = true
[dependencies]
nx9-wg-core.workspace = true
nx9-wg-db.workspace = true
nx9-wireguard.workspace = true
nx9-wg-network.workspace = true
nx9-wg-ui.workspace = true
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
tracing.workspace = true
thiserror.workspace = true
ipnet.workspace = true
sha2.workspace = true
futures-util = "0.3"
[dev-dependencies]
tempfile.workspace = true
+216
View File
@@ -0,0 +1,216 @@
//! Deterministic, collision-free automatic IP address allocation service.
use crate::error::{ApiError, ApiResult};
use ipnet::IpNet;
use nx9_wg_core::types::network::Network;
use nx9_wg_core::types::wireguard::Interface;
use nx9_wg_core::validation::validate_ip_in_network;
use nx9_wg_db::Store;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv6Addr};
use uuid::Uuid;
/// Details of an allocated IP address within a network.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkAllocation {
pub ip_address: String,
pub peer_id: Option<Uuid>,
pub peer_name: Option<String>,
pub peer_state: Option<String>,
pub allocated_at: String,
}
/// Core IP address allocator service.
pub struct IpAllocator;
impl IpAllocator {
/// Deterministically allocate the next available IP in a network CIDR.
/// Excludes:
/// - Network address
/// - Broadcast address (for IPv4)
/// - Interface address
/// - Existing peer allocations (active, disabled, expired)
pub async fn allocate_next_ip(
store: &Store,
network: &Network,
interface: Option<&Interface>,
preferred_ip: Option<IpAddr>,
) -> ApiResult<IpNet> {
let net = network.cidr;
// If preferred IP is supplied, validate and ensure no collision
if let Some(pref) = preferred_ip {
validate_ip_in_network(pref, net).map_err(|e| ApiError::Validation(e.to_string()))?;
let exclusions = Self::get_exclusions(store, network, interface).await?;
if exclusions.contains(&pref) {
return Err(ApiError::Conflict(format!(
"Requested IP '{pref}' is already allocated or reserved in network '{}'",
network.name
)));
}
let cidr_suffix = match net {
IpNet::V4(_) => 32,
IpNet::V6(_) => 128,
};
return IpNet::new(pref, cidr_suffix).map_err(|e| ApiError::Validation(e.to_string()));
}
let exclusions = Self::get_exclusions(store, network, interface).await?;
match net {
IpNet::V4(v4_net) => {
for host in v4_net.hosts() {
let ip = IpAddr::V4(host);
if !exclusions.contains(&ip) {
return Ok(IpNet::V4(
ipnet::Ipv4Net::new(host, 32)
.map_err(|e| ApiError::Validation(e.to_string()))?,
));
}
}
Err(ApiError::Conflict(format!(
"IPv4 network '{}' ({}) is completely exhausted",
network.name, network.cidr
)))
}
IpNet::V6(v6_net) => {
let start_u128 = u128::from(v6_net.network());
// Search up to 65536 host addresses deterministically
for offset in 2u128..65536u128 {
let candidate_u128 = start_u128 + offset;
let candidate = Ipv6Addr::from(candidate_u128);
let ip = IpAddr::V6(candidate);
if v6_net.contains(&candidate) && !exclusions.contains(&ip) {
return Ok(IpNet::V6(
ipnet::Ipv6Net::new(candidate, 128)
.map_err(|e| ApiError::Validation(e.to_string()))?,
));
}
}
Err(ApiError::Conflict(format!(
"IPv6 network '{}' ({}) allocation window exhausted",
network.name, network.cidr
)))
}
}
}
/// List next N available unallocated IP addresses in the network.
pub async fn list_available_ips(
store: &Store,
network: &Network,
interface: Option<&Interface>,
limit: usize,
) -> ApiResult<Vec<IpAddr>> {
let net = network.cidr;
let exclusions = Self::get_exclusions(store, network, interface).await?;
let mut available = Vec::new();
match net {
IpNet::V4(v4_net) => {
for host in v4_net.hosts() {
let ip = IpAddr::V4(host);
if !exclusions.contains(&ip) {
available.push(ip);
if available.len() >= limit {
break;
}
}
}
}
IpNet::V6(v6_net) => {
let start_u128 = u128::from(v6_net.network());
for offset in 2u128..65536u128 {
let candidate_u128 = start_u128 + offset;
let candidate = Ipv6Addr::from(candidate_u128);
let ip = IpAddr::V6(candidate);
if v6_net.contains(&candidate) && !exclusions.contains(&ip) {
available.push(ip);
if available.len() >= limit {
break;
}
}
}
}
}
Ok(available)
}
/// List allocated IP addresses in a network.
pub async fn list_allocations(
store: &Store,
network: &Network,
) -> Result<Vec<NetworkAllocation>, ApiError> {
let all_peers = store.list_all_peers().await?;
let net = network.cidr;
let mut allocations = Vec::new();
for peer in all_peers {
if let Some(v4) = peer.address_v4.filter(|addr| net.contains(&addr.addr())) {
allocations.push(NetworkAllocation {
ip_address: v4.to_string(),
peer_id: Some(peer.id),
peer_name: Some(peer.name.clone()),
peer_state: Some(peer.state.to_string()),
allocated_at: peer.created_at.to_string(),
});
}
if let Some(v6) = peer.address_v6.filter(|addr| net.contains(&addr.addr())) {
allocations.push(NetworkAllocation {
ip_address: v6.to_string(),
peer_id: Some(peer.id),
peer_name: Some(peer.name.clone()),
peer_state: Some(peer.state.to_string()),
allocated_at: peer.created_at.to_string(),
});
}
}
Ok(allocations)
}
/// Helper to compute reserved and allocated exclusion IP set.
async fn get_exclusions(
store: &Store,
network: &Network,
interface: Option<&Interface>,
) -> ApiResult<HashSet<IpAddr>> {
let mut exclusions = HashSet::new();
let net = network.cidr;
// Exclude network and broadcast
match net {
IpNet::V4(v4) => {
exclusions.insert(IpAddr::V4(v4.network()));
exclusions.insert(IpAddr::V4(v4.broadcast()));
}
IpNet::V6(v6) => {
exclusions.insert(IpAddr::V6(v6.network()));
}
}
// Exclude interface addresses
if let Some(iface) = interface {
exclusions.insert(iface.address_v4.addr());
if let Some(v6) = iface.address_v6 {
exclusions.insert(v6.addr());
}
}
// Exclude existing peer allocations
let allocated_strs = store.get_allocated_ips(interface.map(|i| i.id)).await?;
for s in allocated_strs {
if let Ok(ipnet) = s.parse::<IpNet>() {
exclusions.insert(ipnet.addr());
} else if let Ok(ip) = s.parse::<IpAddr>() {
exclusions.insert(ip);
}
}
Ok(exclusions)
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Administrator bootstrap resolution and initial provisioning.
use crate::error::{ApiError, ApiResult};
use nx9_wg_core::config::AppConfig;
use nx9_wg_core::crypto::{generate_secure_password, hash_password};
use nx9_wg_core::types::audit::AuditEventType;
use nx9_wg_core::types::auth::Admin;
use nx9_wg_core::validation::validate_password_strength;
use nx9_wg_db::Store;
use std::path::Path;
/// Options supplied to the administrator bootstrap resolver.
#[derive(Debug, Default, Clone)]
pub struct BootstrapOptions {
/// Administrator username (default: "admin")
pub admin_username: Option<String>,
/// Explicit CLI argument password
pub cli_password: Option<String>,
/// Password file path (e.g. Docker secrets / NX9_WG_ADMIN_PASSWORD_FILE)
pub password_file: Option<String>,
/// Raw password read from stdin
pub stdin_password: Option<String>,
/// Request to generate a cryptographically secure random password
pub generate_password: bool,
/// Optional path to write generated password (chmod 0600)
pub write_password_file: Option<String>,
}
/// Description of which credential source was resolved during bootstrap.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedSource {
CliArgument,
Stdin,
PasswordFile,
EnvironmentVariable,
ConfigFile,
Generated,
}
impl ResolvedSource {
pub fn description(&self) -> &'static str {
match self {
Self::CliArgument => "explicit CLI argument",
Self::Stdin => "standard input (--password-stdin)",
Self::PasswordFile => "password file / secret",
Self::EnvironmentVariable => "environment variable (NX9_WG_ADMIN_PASSWORD)",
Self::ConfigFile => "configuration file ([bootstrap])",
Self::Generated => "secure random generation",
}
}
}
/// Result of a successful administrator bootstrap operation.
pub struct BootstrapResult {
pub admin: Admin,
pub source: ResolvedSource,
pub generated_plaintext: Option<String>,
}
/// Resolve administrator credentials following documented precedence rules and initialize SQLite.
pub async fn bootstrap_admin(
store: &Store,
config: &AppConfig,
opts: &BootstrapOptions,
) -> ApiResult<BootstrapResult> {
// 1. Check if admin already exists
if store.admin_exists().await? {
return Err(ApiError::Conflict(
"Administrator has already been initialized. Use 'nx9-wg admin password' to reset."
.to_string(),
));
}
// 2. Resolve username
let username = opts
.admin_username
.clone()
.or_else(|| std::env::var("NX9_WG_ADMIN_USERNAME").ok())
.or_else(|| {
config
.bootstrap
.as_ref()
.and_then(|b| b.admin_username.clone())
})
.unwrap_or_else(|| "admin".to_string());
// 3. Resolve password following strict precedence:
// 1: Explicit CLI argument
// 2: Stdin
// 3: Password file / NX9_WG_ADMIN_PASSWORD_FILE
// 4: NX9_WG_ADMIN_PASSWORD
// 5: Config file bootstrap credential
// 6: Generated password
let (password, source, generated_plaintext) = if let Some(ref pw) = opts.cli_password {
tracing::warn!(
"Administrator password was supplied via CLI argument. Note that shell history and process lists may expose it. Prefer --admin-password-stdin or secrets files in production."
);
(pw.clone(), ResolvedSource::CliArgument, None)
} else if let Some(ref pw) = opts.stdin_password {
(pw.trim().to_string(), ResolvedSource::Stdin, None)
} else if let Some(ref file_path) = opts
.password_file
.clone()
.or_else(|| std::env::var("NX9_WG_ADMIN_PASSWORD_FILE").ok())
{
let content = std::fs::read_to_string(file_path).map_err(|e| {
ApiError::BadRequest(format!("Failed to read password file '{file_path}': {e}"))
})?;
(
content.trim().to_string(),
ResolvedSource::PasswordFile,
None,
)
} else if let Ok(env_pw) = std::env::var("NX9_WG_ADMIN_PASSWORD") {
(env_pw, ResolvedSource::EnvironmentVariable, None)
} else if let Some(ref boot) = config.bootstrap {
if let Some(ref pw) = boot.admin_password {
(pw.clone(), ResolvedSource::ConfigFile, None)
} else if opts.generate_password {
let generated_pw = generate_secure_password(24);
(
generated_pw.clone(),
ResolvedSource::Generated,
Some(generated_pw),
)
} else {
return Err(ApiError::BadRequest(
"No administrator password provided. Use 'nx9-wg init' or configure credentials."
.to_string(),
));
}
} else if opts.generate_password {
let generated_pw = generate_secure_password(24);
(
generated_pw.clone(),
ResolvedSource::Generated,
Some(generated_pw),
)
} else {
return Err(ApiError::BadRequest(
"No administrator password provided. Use 'nx9-wg init' or configure credentials."
.to_string(),
));
};
// 4. Validate password strength
validate_password_strength(&password)?;
// 5. Convert to Argon2id hash immediately
let hash = hash_password(&password)?;
// 6. If password was generated and a file output was requested, write with 0600 permissions
if let (Some(generated_pw), Some(path_str)) = (&generated_plaintext, &opts.write_password_file)
{
let path = Path::new(path_str);
if let Some(parent) = path
.parent()
.filter(|p| !p.as_os_str().is_empty() && !p.exists())
{
std::fs::create_dir_all(parent).map_err(|e| {
ApiError::Internal(format!("Failed to create password file directory: {e}"))
})?;
}
std::fs::write(path, generated_pw)
.map_err(|e| ApiError::Internal(format!("Failed to write password file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
// 7. Persist admin in SQLite (hash only, id=1)
let admin = store.create_admin(&username, &hash).await?;
// 8. Record audit event
let _ = store
.record_audit(
AuditEventType::AdminInitialized,
&username,
Some("admin"),
Some("1"),
Some(&format!(
"Administrator initialized via {}",
source.description()
)),
None,
None,
)
.await;
tracing::info!(
username = %username,
source = %source.description(),
"Administrator initialized successfully"
);
Ok(BootstrapResult {
admin,
source,
generated_plaintext,
})
}
+73
View File
@@ -0,0 +1,73 @@
//! Authentication middleware for Axum endpoints.
use crate::auth::service::AuthService;
use crate::error::ApiError;
use axum::extract::Request;
use axum::http::header::{AUTHORIZATION, COOKIE};
use axum::middleware::Next;
use axum::response::Response;
/// Authenticated identity attached to request extensions.
#[derive(Debug, Clone)]
pub struct AuthenticatedAdmin {
pub username: String,
pub session_id: Option<String>,
pub token_id: Option<String>,
}
/// Require authentication middleware: validates either a session cookie or a Bearer token.
pub async fn require_auth(
axum::extract::State(auth): axum::extract::State<AuthService>,
mut req: Request,
next: Next,
) -> Result<Response, ApiError> {
// 1. Try Bearer token in Authorization header
if let Some(token) = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
{
let token = token.trim();
if token.starts_with("nx9_") {
// API token
let api_token = auth.authenticate_token(token).await?;
req.extensions_mut().insert(AuthenticatedAdmin {
username: "admin".to_string(),
session_id: None,
token_id: Some(api_token.id),
});
return Ok(next.run(req).await);
} else {
// Session ID in Bearer header
let session = auth.authenticate_session(token).await?;
req.extensions_mut().insert(AuthenticatedAdmin {
username: "admin".to_string(),
session_id: Some(session.id),
token_id: None,
});
return Ok(next.run(req).await);
}
}
// 2. Try session cookie (nx9_session=...)
if let Some(cookie_header) = req.headers().get(COOKIE).and_then(|v| v.to_str().ok()) {
for cookie in cookie_header.split(';') {
let cookie = cookie.trim();
if let Some(session_id) = cookie.strip_prefix("nx9_session=") {
let session_id = session_id.trim();
let session = auth.authenticate_session(session_id).await?;
req.extensions_mut().insert(AuthenticatedAdmin {
username: "admin".to_string(),
session_id: Some(session.id),
token_id: None,
});
return Ok(next.run(req).await);
}
}
}
Err(ApiError::Unauthenticated(
"Authentication required. Provide a valid session cookie or Bearer token.".to_string(),
))
}
+9
View File
@@ -0,0 +1,9 @@
//! Authentication and security subsystem.
pub mod bootstrap;
pub mod middleware;
pub mod service;
pub use bootstrap::{BootstrapOptions, BootstrapResult, ResolvedSource, bootstrap_admin};
pub use middleware::{AuthenticatedAdmin, require_auth};
pub use service::AuthService;
+354
View File
@@ -0,0 +1,354 @@
//! Authentication service handling login, session validation, API tokens, and password rotation.
use crate::error::{ApiError, ApiResult};
use chrono::{Duration, NaiveDateTime, Utc};
use nx9_wg_core::crypto::{
generate_api_token, generate_session_id, hash_password, verify_password,
};
use nx9_wg_core::types::audit::AuditEventType;
use nx9_wg_core::types::auth::{ApiToken, Session};
use nx9_wg_core::validation::validate_password_strength;
use nx9_wg_db::Store;
use sha2::{Digest, Sha256};
/// Maximum failed login attempts allowed within the sliding window.
pub const MAX_FAILED_ATTEMPTS: i64 = 5;
/// Sliding window duration in minutes for login rate limiting.
pub const RATE_LIMIT_WINDOW_MINUTES: i64 = 15;
/// Default session validity duration.
pub const DEFAULT_SESSION_DURATION_HOURS: i64 = 24;
/// Authentication service wrapping database operations and cryptographic validation.
#[derive(Debug, Clone)]
pub struct AuthService {
store: Store,
session_duration: Duration,
}
impl AuthService {
/// Create a new AuthService instance.
pub fn new(store: Store) -> Self {
Self {
store,
session_duration: Duration::hours(DEFAULT_SESSION_DURATION_HOURS),
}
}
/// Create an AuthService with custom session expiry duration.
pub fn with_session_duration(store: Store, session_duration: Duration) -> Self {
Self {
store,
session_duration,
}
}
/// Get underlying database store handle.
pub fn store(&self) -> &Store {
&self.store
}
/// Authenticate administrator with username and password, rate-limiting on failure.
pub async fn login(
&self,
username: &str,
password: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> ApiResult<Session> {
let client_ip = ip_address.unwrap_or("127.0.0.1");
// 1. Check rate limit
let failed_count = self
.store
.count_recent_failed_attempts(client_ip, RATE_LIMIT_WINDOW_MINUTES)
.await?;
if failed_count >= MAX_FAILED_ATTEMPTS {
let msg = format!(
"Too many failed login attempts from IP {client_ip}. Please wait {RATE_LIMIT_WINDOW_MINUTES} minutes before retrying."
);
tracing::warn!(ip = %client_ip, "Login rate-limit lockout triggered");
return Err(ApiError::RateLimited(msg));
}
// 2. Fetch admin
let admin = match self.store.get_admin().await? {
Some(a) => a,
None => {
let _ = self.store.record_login_attempt(client_ip, false).await;
return Err(ApiError::Unauthenticated(
"Administrator not initialized. Please run bootstrap first.".to_string(),
));
}
};
// 3. Verify username
if admin.username != username {
let _ = self.store.record_login_attempt(client_ip, false).await;
let _ = self
.store
.record_audit(
AuditEventType::LoginFailed,
username,
Some("auth"),
None,
Some("Invalid username during login attempt"),
None,
Some(client_ip),
)
.await;
return Err(ApiError::Unauthenticated(
"Invalid administrator username or password".to_string(),
));
}
// 4. Verify password with Argon2id
let valid = verify_password(password, &admin.password_hash)?;
if !valid {
let _ = self.store.record_login_attempt(client_ip, false).await;
let _ = self
.store
.record_audit(
AuditEventType::LoginFailed,
username,
Some("auth"),
None,
Some("Invalid password during login attempt"),
None,
Some(client_ip),
)
.await;
return Err(ApiError::Unauthenticated(
"Invalid administrator username or password".to_string(),
));
}
// 5. Successful login: clear failed attempts and record login details
let _ = self.store.record_login_attempt(client_ip, true).await;
let _ = self.store.clear_login_attempts(client_ip).await;
let _ = self.store.record_admin_login(Some(client_ip)).await;
// 6. Create session
let session_id = generate_session_id();
let now = Utc::now().naive_utc();
let session = Session {
id: session_id,
admin_id: 1,
created_at: now,
expires_at: now + self.session_duration,
last_seen_at: Some(now),
ip_address: ip_address.map(|s| s.to_string()),
user_agent: user_agent.map(|s| s.to_string()),
};
self.store.create_session(&session).await?;
// 7. Audit log
let _ = self
.store
.record_audit(
AuditEventType::Login,
username,
Some("session"),
Some(&session.id),
Some("Administrator login successful"),
None,
Some(client_ip),
)
.await;
Ok(session)
}
/// Authenticate an incoming request by session ID.
pub async fn authenticate_session(&self, session_id: &str) -> ApiResult<Session> {
let session = match self.store.get_session(session_id).await? {
Some(s) => s,
None => return Err(ApiError::Unauthenticated("Session not found".to_string())),
};
let now = Utc::now().naive_utc();
if session.expires_at < now {
// Delete expired session
let _ = self.store.delete_session(session_id).await;
return Err(ApiError::Unauthenticated("Session has expired".to_string()));
}
// Touch session
let _ = self.store.touch_session(session_id).await;
Ok(session)
}
/// Authenticate an incoming request by raw API token.
pub async fn authenticate_token(&self, raw_token: &str) -> ApiResult<ApiToken> {
let hash_bytes = Sha256::digest(raw_token.as_bytes());
let token_hash = hash_bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
let token = match self.store.find_token_by_hash(&token_hash).await? {
Some(t) => t,
None => return Err(ApiError::Unauthenticated("Invalid API token".to_string())),
};
if token.revoked {
return Err(ApiError::Unauthenticated(
"API token has been revoked".to_string(),
));
}
let now = Utc::now().naive_utc();
if token.expires_at.is_some_and(|exp| exp < now) {
return Err(ApiError::Unauthenticated(
"API token has expired".to_string(),
));
}
// Mark token used
let _ = self.store.mark_token_used(&token.id).await;
Ok(token)
}
/// Change administrator password and invalidate all active sessions.
pub async fn change_password(
&self,
new_password: &str,
ip_address: Option<&str>,
) -> ApiResult<()> {
validate_password_strength(new_password)?;
let hash = hash_password(new_password)?;
self.store.update_admin_password(&hash).await?;
// Invalidate all existing sessions globally
let invalidated = self.store.delete_all_admin_sessions(1).await?;
// Audit password change and session invalidation
let _ = self
.store
.record_audit(
AuditEventType::PasswordChange,
"admin",
Some("admin"),
Some("1"),
Some("Administrator password changed successfully"),
None,
ip_address,
)
.await;
let _ = self
.store
.record_audit(
AuditEventType::SessionInvalidated,
"admin",
Some("session"),
None,
Some(&format!(
"Invalidated {invalidated} sessions after password change"
)),
None,
ip_address,
)
.await;
tracing::info!(
invalidated_sessions = invalidated,
"Administrator password changed; all active sessions invalidated"
);
Ok(())
}
/// Logout and invalidate a specific session.
pub async fn logout(&self, session_id: &str, ip_address: Option<&str>) -> ApiResult<()> {
self.store.delete_session(session_id).await?;
let _ = self
.store
.record_audit(
AuditEventType::Logout,
"admin",
Some("session"),
Some(session_id),
Some("Administrator logged out"),
None,
ip_address,
)
.await;
Ok(())
}
/// Create a new API token. Returns the stored ApiToken metadata and the raw plaintext token (shown only once).
pub async fn create_api_token(
&self,
name: &str,
expires_at: Option<NaiveDateTime>,
ip_address: Option<&str>,
) -> ApiResult<(ApiToken, String)> {
if name.trim().is_empty() {
return Err(ApiError::Validation(
"API token name cannot be empty".to_string(),
));
}
let (raw_token, token_hash) = generate_api_token();
let token_id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().naive_utc();
let token = ApiToken {
id: token_id.clone(),
admin_id: 1,
name: name.to_string(),
token_hash,
created_at: now,
expires_at,
last_used_at: None,
revoked_at: None,
revoked: false,
};
self.store.create_token(&token).await?;
let _ = self
.store
.record_audit(
AuditEventType::ApiTokenCreate,
"admin",
Some("api_token"),
Some(&token_id),
Some(&format!("API token '{name}' created")),
None,
ip_address,
)
.await;
Ok((token, raw_token))
}
/// Revoke an API token by ID.
pub async fn revoke_api_token(&self, id: &str, ip_address: Option<&str>) -> ApiResult<()> {
self.store.revoke_token(id).await?;
let _ = self
.store
.record_audit(
AuditEventType::ApiTokenRevoke,
"admin",
Some("api_token"),
Some(id),
Some("API token revoked"),
None,
ip_address,
)
.await;
Ok(())
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Backup and Restore engine for consistent SQLite snapshots and manifests.
use crate::error::{ApiError, ApiResult};
use chrono::Utc;
use nx9_wg_core::types::audit::AuditEventType;
use nx9_wg_core::types::backup::{BackupFileEntry, BackupManifest, BackupMeta};
use nx9_wg_db::Store;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use uuid::Uuid;
/// Backup and restore management service.
pub struct BackupService;
impl BackupService {
/// Create a consistent, atomic SQLite snapshot backup and manifest.
pub async fn create_backup(
store: &Store,
backup_dir: &Path,
description: Option<&str>,
actor: &str,
ip_address: Option<&str>,
) -> ApiResult<(BackupMeta, PathBuf)> {
if !backup_dir.exists() {
std::fs::create_dir_all(backup_dir).map_err(|e| {
ApiError::Internal(format!("Failed to create backup directory: {e}"))
})?;
}
let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
let filename = format!("nx9-backup-{timestamp}.db");
let backup_path = backup_dir.join(&filename);
let backup_path_str = backup_path.to_string_lossy().to_string();
// 1. Perform atomic SQLite VACUUM INTO
store.vacuum_into(&backup_path_str).await?;
// 2. Read bytes to compute checksum and size
let bytes = std::fs::read(&backup_path)
.map_err(|e| ApiError::Internal(format!("Failed to read created backup file: {e}")))?;
let size_bytes = bytes.len() as i64;
let hash_bytes = Sha256::digest(&bytes);
let checksum = hash_bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
let now = Utc::now().naive_utc();
let backup_id = Uuid::new_v4();
let meta = BackupMeta {
id: backup_id,
filename: filename.clone(),
size_bytes,
checksum: checksum.clone(),
schema_version: "1".to_string(),
encrypted: false,
description: description.map(|s| s.to_string()),
created_at: now,
};
// 3. Write manifest file
let manifest = BackupManifest {
version: env!("CARGO_PKG_VERSION").to_string(),
schema_version: "1".to_string(),
created_at: now,
checksum: checksum.clone(),
encrypted: false,
files: vec![BackupFileEntry {
path: filename.clone(),
size_bytes: size_bytes as u64,
checksum: checksum.clone(),
}],
notes: description.map(|s| s.to_string()),
};
let manifest_path = backup_dir.join(format!("nx9-backup-{timestamp}.manifest.json"));
let manifest_json = serde_json::to_string_pretty(&manifest)
.map_err(|e| ApiError::Internal(format!("Failed to serialize backup manifest: {e}")))?;
std::fs::write(&manifest_path, manifest_json)
.map_err(|e| ApiError::Internal(format!("Failed to write backup manifest: {e}")))?;
// 4. Save metadata in SQLite
store.create_backup_meta(&meta).await?;
// 5. Audit event
let _ = store
.record_audit(
AuditEventType::BackupCreate,
actor,
Some("backup"),
Some(&backup_id.to_string()),
Some(&format!("Created backup '{filename}' ({size_bytes} bytes)")),
None,
ip_address,
)
.await;
Ok((meta, backup_path))
}
/// Verify the integrity and SQLite magic header of a backup file.
pub fn verify_backup(backup_file: &Path, expected_checksum: Option<&str>) -> ApiResult<bool> {
if !backup_file.exists() {
return Err(ApiError::NotFound(format!(
"Backup file '{}' not found",
backup_file.display()
)));
}
let bytes = std::fs::read(backup_file).map_err(|e| {
ApiError::Internal(format!("Failed to read backup file for verification: {e}"))
})?;
if bytes.len() < 100 {
return Ok(false);
}
// Verify SQLite 3 header magic
if &bytes[0..16] != b"SQLite format 3\0" {
return Ok(false);
}
// Verify checksum if supplied
if let Some(expected) = expected_checksum {
let hash_bytes = Sha256::digest(&bytes);
let calculated = hash_bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
if calculated.to_lowercase() != expected.to_lowercase() {
return Ok(false);
}
}
Ok(true)
}
/// Restore database from a verified backup file with safety pre-restore backup snapshot.
pub async fn restore_backup(
store: &Store,
backup_file: &Path,
active_db_path: &Path,
safety_dir: &Path,
actor: &str,
ip_address: Option<&str>,
) -> ApiResult<()> {
// 1. Verify backup file before touching active DB
let is_valid = Self::verify_backup(backup_file, None)?;
if !is_valid {
return Err(ApiError::Validation(
"Backup file failed verification: invalid SQLite format or corrupted data"
.to_string(),
));
}
// 2. Create pre-restore safety snapshot of the active database
if active_db_path.exists() {
if !safety_dir.exists() {
let _ = std::fs::create_dir_all(safety_dir);
}
let safety_name = format!(
"pre-restore-safety-{}.bak",
Utc::now().format("%Y%m%d-%H%M%S")
);
let safety_path = safety_dir.join(safety_name);
let _ = store.vacuum_into(&safety_path.to_string_lossy()).await;
}
// 3. Record audit event before closing pool
let _ = store
.record_audit(
AuditEventType::BackupRestore,
actor,
Some("backup"),
None,
Some(&format!(
"Database restore initiated from '{}'",
backup_file.display()
)),
None,
ip_address,
)
.await;
// 4. Close store pool to release file locks
store.close().await;
// 5. Clean up existing active database and WAL, SHM, and journal files
let wal_path = PathBuf::from(format!("{}-wal", active_db_path.display()));
let shm_path = PathBuf::from(format!("{}-shm", active_db_path.display()));
let journal_path = PathBuf::from(format!("{}-journal", active_db_path.display()));
let _ = std::fs::remove_file(wal_path);
let _ = std::fs::remove_file(shm_path);
let _ = std::fs::remove_file(journal_path);
let _ = std::fs::remove_file(active_db_path);
// 6. Copy backup file to active database location
std::fs::copy(backup_file, active_db_path)
.map_err(|e| ApiError::Internal(format!("Failed to restore database file: {e}")))?;
Ok(())
}
}
+836
View File
@@ -0,0 +1,836 @@
//! Native diagnostics service for WireGuard, Linux networking, kernel sysctl, and reconciliation.
use crate::error::ApiResult;
use crate::reconciliation::ReconciliationEngine;
use crate::state::AppState;
use chrono::Utc;
use nx9_wg_core::types::diagnostics::{
DiagnosticCheck, DiagnosticReport, DiagnosticStatus, DiagnosticSubsystem,
};
use nx9_wg_network::NetworkEngine;
use nx9_wireguard::WireGuardEngine;
use std::sync::Arc;
use uuid::Uuid;
/// Native diagnostics inspection service.
pub struct DiagnosticsService {
state: AppState,
wg_engine: Arc<dyn WireGuardEngine>,
net_engine: Arc<dyn NetworkEngine>,
reconciler: Arc<ReconciliationEngine>,
}
impl DiagnosticsService {
/// Create a new diagnostics service.
pub fn new(
state: AppState,
wg_engine: Arc<dyn WireGuardEngine>,
net_engine: Arc<dyn NetworkEngine>,
reconciler: Arc<ReconciliationEngine>,
) -> Self {
Self {
state,
wg_engine,
net_engine,
reconciler,
}
}
/// Run diagnostic check for a target subsystem.
pub async fn run_diagnostic(
&self,
subsystem: DiagnosticSubsystem,
peer_id: Option<Uuid>,
) -> ApiResult<Vec<DiagnosticReport>> {
match subsystem {
DiagnosticSubsystem::System => Ok(vec![self.diagnose_system().await?]),
DiagnosticSubsystem::Network => Ok(vec![self.diagnose_network().await?]),
DiagnosticSubsystem::Wan => Ok(vec![self.diagnose_wan().await?]),
DiagnosticSubsystem::Wireguard => Ok(vec![self.diagnose_wireguard(None).await?]),
DiagnosticSubsystem::Peer => {
if let Some(id) = peer_id {
Ok(vec![self.diagnose_peer(id).await?])
} else {
let peers = self.state.store.list_all_peers().await?;
let mut reports = Vec::new();
for p in peers {
reports.push(self.diagnose_peer(p.id).await?);
}
if reports.is_empty() {
reports.push(DiagnosticReport {
subsystem: "peer".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: DiagnosticStatus::Pass,
checks: vec![DiagnosticCheck {
check_name: "enrolled_peers".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "0 peers".to_string(),
expected_value: None,
diagnostic_message:
"No peers are currently enrolled in the database".to_string(),
remediation_hint: None,
}],
});
}
Ok(reports)
}
}
DiagnosticSubsystem::Routing => Ok(vec![self.diagnose_routing().await?]),
DiagnosticSubsystem::Forwarding => Ok(vec![self.diagnose_forwarding().await?]),
DiagnosticSubsystem::Firewall => Ok(vec![self.diagnose_firewall().await?]),
DiagnosticSubsystem::Nat => Ok(vec![self.diagnose_nat().await?]),
DiagnosticSubsystem::Mtu => Ok(vec![self.diagnose_mtu().await?]),
DiagnosticSubsystem::Reconciliation => Ok(vec![self.diagnose_reconciliation().await?]),
DiagnosticSubsystem::All => self.diagnose_all().await,
}
}
/// System subsystem diagnostics.
pub async fn diagnose_system(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
// Hostname
let hostname = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "localhost".to_string());
checks.push(DiagnosticCheck {
check_name: "hostname".to_string(),
status: DiagnosticStatus::Pass,
observed_value: hostname,
expected_value: None,
diagnostic_message: "System hostname read successfully".to_string(),
remediation_hint: None,
});
// OS and Architecture
checks.push(DiagnosticCheck {
check_name: "os_architecture".to_string(),
status: DiagnosticStatus::Pass,
observed_value: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
expected_value: Some("linux-*".to_string()),
diagnostic_message: "Supported target platform".to_string(),
remediation_hint: None,
});
// Kernel Version
let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "Linux".to_string());
checks.push(DiagnosticCheck {
check_name: "kernel_version".to_string(),
status: DiagnosticStatus::Pass,
observed_value: kernel,
expected_value: None,
diagnostic_message: "Linux kernel release inspected".to_string(),
remediation_hint: None,
});
// Memory Info
if let Ok(mem) = std::fs::read_to_string("/proc/meminfo") {
let mem_total = mem
.lines()
.find(|l| l.starts_with("MemTotal:"))
.unwrap_or("MemTotal: unknown");
checks.push(DiagnosticCheck {
check_name: "memory_status".to_string(),
status: DiagnosticStatus::Pass,
observed_value: mem_total.to_string(),
expected_value: None,
diagnostic_message: "System memory available".to_string(),
remediation_hint: None,
});
}
// Database Health Check
let db_health = self.state.store.health_check().await;
match db_health {
Ok(_) => checks.push(DiagnosticCheck {
check_name: "sqlite_persistence".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "connected_and_healthy".to_string(),
expected_value: Some("connected_and_healthy".to_string()),
diagnostic_message: "SQLite WAL persistence layer is responsive".to_string(),
remediation_hint: None,
}),
Err(e) => checks.push(DiagnosticCheck {
check_name: "sqlite_persistence".to_string(),
status: DiagnosticStatus::Fail,
observed_value: format!("error: {e}"),
expected_value: Some("connected_and_healthy".to_string()),
diagnostic_message: "Database connectivity failure".to_string(),
remediation_hint: Some(
"Verify database file permissions and disk space".to_string(),
),
}),
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "system".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// Network subsystem diagnostics.
pub async fn diagnose_network(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
// Interface device list
if let Ok(devs) = std::fs::read_to_string("/proc/net/dev") {
let iface_names: Vec<String> = devs
.lines()
.skip(2)
.filter_map(|l| l.split(':').next().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.collect();
checks.push(DiagnosticCheck {
check_name: "linux_network_interfaces".to_string(),
status: DiagnosticStatus::Pass,
observed_value: format!(
"{} interfaces ({})",
iface_names.len(),
iface_names.join(", ")
),
expected_value: None,
diagnostic_message: "Network interfaces discovered in kernel".to_string(),
remediation_hint: None,
});
}
// DNS Configuration
let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
let nameservers: Vec<&str> = resolv
.lines()
.filter(|l| l.starts_with("nameserver"))
.filter_map(|l| l.split_whitespace().nth(1))
.collect();
if nameservers.is_empty() {
checks.push(DiagnosticCheck {
check_name: "dns_nameservers".to_string(),
status: DiagnosticStatus::Warning,
observed_value: "none_configured".to_string(),
expected_value: Some("valid nameserver entries".to_string()),
diagnostic_message: "No DNS nameservers found in /etc/resolv.conf".to_string(),
remediation_hint: Some(
"Configure DNS servers in /etc/resolv.conf or interface settings".to_string(),
),
});
} else {
checks.push(DiagnosticCheck {
check_name: "dns_nameservers".to_string(),
status: DiagnosticStatus::Pass,
observed_value: nameservers.join(", "),
expected_value: None,
diagnostic_message: "System DNS nameservers configured".to_string(),
remediation_hint: None,
});
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "network".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// WAN and external reachability diagnostics.
pub async fn diagnose_wan(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
// Default Route check
let routes = std::fs::read_to_string("/proc/net/route").unwrap_or_default();
let has_default_gateway = routes.lines().skip(1).any(|l| {
let cols: Vec<&str> = l.split_whitespace().collect();
cols.len() > 1 && cols[1] == "00000000"
});
if has_default_gateway {
checks.push(DiagnosticCheck {
check_name: "default_gateway_route".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "default_gateway_present".to_string(),
expected_value: Some("default_gateway_present".to_string()),
diagnostic_message: "Default route to WAN/gateway is present".to_string(),
remediation_hint: None,
});
} else {
checks.push(DiagnosticCheck {
check_name: "default_gateway_route".to_string(),
status: DiagnosticStatus::Warning,
observed_value: "missing_default_gateway".to_string(),
expected_value: Some("default_gateway_present".to_string()),
diagnostic_message:
"No default gateway (0.0.0.0/0) detected in kernel routing table".to_string(),
remediation_hint: Some(
"Verify network connection or add a default route using 'nx9-wg route add'"
.to_string(),
),
});
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "wan".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// WireGuard interface diagnostics.
pub async fn diagnose_wireguard(
&self,
interface_name: Option<&str>,
) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let interfaces = self.state.store.list_interfaces().await?;
if interfaces.is_empty() {
checks.push(DiagnosticCheck {
check_name: "configured_interfaces".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "0 interfaces".to_string(),
expected_value: None,
diagnostic_message: "No WireGuard interfaces configured yet".to_string(),
remediation_hint: Some(
"Create an interface using 'nx9-wg interface create'".to_string(),
),
});
}
for iface in &interfaces {
if interface_name.is_some_and(|target| iface.name != target) {
continue;
}
let live_stats = self
.wg_engine
.get_interface_stats(&iface.name)
.await
.ok()
.flatten();
match live_stats {
Some(stats) => {
checks.push(DiagnosticCheck {
check_name: format!("interface_{}_status", iface.name),
status: DiagnosticStatus::Pass,
observed_value: format!(
"active: port {}, peers {}",
stats.listen_port,
stats.peers.len()
),
expected_value: Some(format!("port {}", iface.listen_port)),
diagnostic_message: format!(
"Interface '{}' is running and responsive",
iface.name
),
remediation_hint: None,
});
}
None => {
if iface.enabled {
checks.push(DiagnosticCheck {
check_name: format!("interface_{}_status", iface.name),
status: DiagnosticStatus::Warning,
observed_value: "down_or_uninitialized".to_string(),
expected_value: Some("running".to_string()),
diagnostic_message: format!(
"Interface '{}' is enabled in database but not active in kernel",
iface.name
),
remediation_hint: Some(
"Run 'nx9-wg reconcile apply' to synchronize interface to kernel"
.to_string(),
),
});
} else {
checks.push(DiagnosticCheck {
check_name: format!("interface_{}_status", iface.name),
status: DiagnosticStatus::Pass,
observed_value: "administratively_disabled".to_string(),
expected_value: Some("disabled".to_string()),
diagnostic_message: format!(
"Interface '{}' is disabled as intended",
iface.name
),
remediation_hint: None,
});
}
}
}
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "wireguard".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// Single peer diagnostics.
pub async fn diagnose_peer(&self, peer_id: Uuid) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let peer = self.state.store.get_peer(peer_id).await?;
match peer {
Some(p) => {
// Peer State
checks.push(DiagnosticCheck {
check_name: "lifecycle_state".to_string(),
status: match p.state {
nx9_wg_core::types::wireguard::PeerState::Active => DiagnosticStatus::Pass,
nx9_wg_core::types::wireguard::PeerState::Disabled => {
DiagnosticStatus::Warning
}
nx9_wg_core::types::wireguard::PeerState::Expired => {
DiagnosticStatus::Warning
}
nx9_wg_core::types::wireguard::PeerState::Revoked => DiagnosticStatus::Fail,
},
observed_value: p.state.to_string(),
expected_value: Some("active".to_string()),
diagnostic_message: format!("Peer '{}' is in '{}' state", p.name, p.state),
remediation_hint: match p.state {
nx9_wg_core::types::wireguard::PeerState::Expired => {
Some("Extend or renew peer expiration date".to_string())
}
nx9_wg_core::types::wireguard::PeerState::Disabled => {
Some("Enable peer using 'nx9-wg peer enable'".to_string())
}
_ => None,
},
});
// Address allocation
let v4_str = p
.address_v4
.map(|a| a.to_string())
.unwrap_or_else(|| "none".to_string());
checks.push(DiagnosticCheck {
check_name: "assigned_address".to_string(),
status: if p.address_v4.is_some() {
DiagnosticStatus::Pass
} else {
DiagnosticStatus::Warning
},
observed_value: v4_str,
expected_value: Some("valid CIDR".to_string()),
diagnostic_message: format!(
"Peer address assignment: allowed_ips={}",
p.allowed_ips
),
remediation_hint: None,
});
// Expiration timeline
if let Some(exp) = p.expires_at {
let now = Utc::now().naive_utc();
if exp <= now {
checks.push(DiagnosticCheck {
check_name: "expiration_status".to_string(),
status: DiagnosticStatus::Warning,
observed_value: format!("expired_at_{exp}"),
expected_value: Some("future_expiration".to_string()),
diagnostic_message: "Peer expiration timestamp has elapsed".to_string(),
remediation_hint: Some(
"Update peer expiration date to restore access".to_string(),
),
});
} else {
checks.push(DiagnosticCheck {
check_name: "expiration_status".to_string(),
status: DiagnosticStatus::Pass,
observed_value: format!("valid_until_{exp}"),
expected_value: None,
diagnostic_message: "Peer credential is within validity period"
.to_string(),
remediation_hint: None,
});
}
}
}
None => {
checks.push(DiagnosticCheck {
check_name: "peer_lookup".to_string(),
status: DiagnosticStatus::Fail,
observed_value: "not_found".to_string(),
expected_value: Some("valid_peer_record".to_string()),
diagnostic_message: format!(
"Peer '{peer_id}' does not exist in SQLite database"
),
remediation_hint: Some("Verify peer ID with 'nx9-wg peer list'".to_string()),
});
}
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: format!("peer:{}", peer_id),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// Routing subsystem diagnostics.
pub async fn diagnose_routing(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let routes = self.state.store.list_routes().await?;
let active_routes: Vec<_> = routes.iter().filter(|r| r.enabled).collect();
checks.push(DiagnosticCheck {
check_name: "configured_routes".to_string(),
status: DiagnosticStatus::Pass,
observed_value: format!("{} total ({} active)", routes.len(), active_routes.len()),
expected_value: None,
diagnostic_message: "Kernel routing rules configured in database".to_string(),
remediation_hint: None,
});
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "routing".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// IP packet forwarding diagnostics.
pub async fn diagnose_forwarding(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let fwd = self.net_engine.get_forwarding_status().await;
match fwd {
Ok(status) => {
checks.push(DiagnosticCheck {
check_name: "ipv4_forwarding".to_string(),
status: if status.ipv4_enabled { DiagnosticStatus::Pass } else { DiagnosticStatus::Warning },
observed_value: if status.ipv4_enabled { "enabled".to_string() } else { "disabled".to_string() },
expected_value: Some("enabled".to_string()),
diagnostic_message: if status.ipv4_enabled {
"IPv4 packet forwarding is enabled in sysctl".to_string()
} else {
"IPv4 packet forwarding is disabled in sysctl; VPN clients cannot route traffic".to_string()
},
remediation_hint: if !status.ipv4_enabled {
Some("Enable IP forwarding with 'nx9-wg forwarding enable'".to_string())
} else {
None
},
});
}
Err(e) => {
checks.push(DiagnosticCheck {
check_name: "forwarding_sysctl_read".to_string(),
status: DiagnosticStatus::Fail,
observed_value: format!("error: {e}"),
expected_value: Some("readable".to_string()),
diagnostic_message: "Failed to read kernel forwarding state".to_string(),
remediation_hint: Some("Verify /proc filesystem is mounted".to_string()),
});
}
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "forwarding".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// Firewall subsystem diagnostics.
pub async fn diagnose_firewall(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let rules = self.state.store.list_firewall_rules().await?;
let active_rules: Vec<_> = rules.iter().filter(|r| r.enabled).collect();
checks.push(DiagnosticCheck {
check_name: "firewall_rules_count".to_string(),
status: DiagnosticStatus::Pass,
observed_value: format!("{} total ({} active)", rules.len(), active_rules.len()),
expected_value: None,
diagnostic_message: "Configured nftables packet filtering rules".to_string(),
remediation_hint: None,
});
let active_nft = self.net_engine.get_active_nftables_ruleset().await;
match active_nft {
Ok(ruleset) => {
let has_table = ruleset.contains("table inet nx9_wg");
checks.push(DiagnosticCheck {
check_name: "nftables_table_nx9_wg".to_string(),
status: if has_table {
DiagnosticStatus::Pass
} else {
DiagnosticStatus::Warning
},
observed_value: if has_table {
"active".to_string()
} else {
"not_loaded".to_string()
},
expected_value: Some("active".to_string()),
diagnostic_message: "Dedicated table inet nx9_wg presence in kernel nftables"
.to_string(),
remediation_hint: if !has_table {
Some("Synchronize firewall with 'nx9-wg firewall sync'".to_string())
} else {
None
},
});
}
Err(e) => {
checks.push(DiagnosticCheck {
check_name: "nftables_access".to_string(),
status: DiagnosticStatus::Warning,
observed_value: format!("error: {e}"),
expected_value: Some("accessible".to_string()),
diagnostic_message: "Could not inspect live nftables ruleset".to_string(),
remediation_hint: Some("Verify CAP_NET_ADMIN / root permissions".to_string()),
});
}
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "firewall".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// NAT masquerade diagnostics.
pub async fn diagnose_nat(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let nat_setting = self
.state
.store
.get_setting("enable_nat")
.await?
.map(|s| s.value == "true" || s.value == "1")
.unwrap_or(true);
checks.push(DiagnosticCheck {
check_name: "nat_setting".to_string(),
status: DiagnosticStatus::Pass,
observed_value: if nat_setting {
"enabled".to_string()
} else {
"disabled".to_string()
},
expected_value: None,
diagnostic_message: "NAT masquerade setting configured in database".to_string(),
remediation_hint: None,
});
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "nat".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// MTU consistency and client profile diagnostics.
pub async fn diagnose_mtu(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let interfaces = self.state.store.list_interfaces().await?;
let peers = self.state.store.list_all_peers().await?;
// 1. Interface MTU Checks
for iface in &interfaces {
let mtu = iface.mtu.unwrap_or(1420);
if mtu > 1500 {
checks.push(DiagnosticCheck {
check_name: format!("server_mtu_{}", iface.name),
status: DiagnosticStatus::Warning,
observed_value: format!("{mtu} bytes (jumbo)"),
expected_value: Some("1420 bytes (<= 1500)".to_string()),
diagnostic_message: format!(
"Interface '{}' MTU ({mtu}) exceeds standard physical MTU 1500; may cause fragmentation on WAN egress",
iface.name
),
remediation_hint: Some(
"Set WireGuard server MTU to 1420 to prevent packet fragmentation".to_string(),
),
});
} else if mtu < 1280 {
checks.push(DiagnosticCheck {
check_name: format!("server_mtu_{}", iface.name),
status: DiagnosticStatus::Fail,
observed_value: format!("{mtu} bytes"),
expected_value: Some(">= 1280 bytes".to_string()),
diagnostic_message: format!(
"Interface '{}' MTU ({mtu}) is below the IPv6 minimum MTU (1280)",
iface.name
),
remediation_hint: Some(
"Increase interface MTU to at least 1280 bytes".to_string(),
),
});
} else {
checks.push(DiagnosticCheck {
check_name: format!("server_mtu_{}", iface.name),
status: DiagnosticStatus::Pass,
observed_value: format!("{mtu} bytes"),
expected_value: None,
diagnostic_message: format!(
"Server interface '{}' MTU ({mtu}) is within safe WAN limits (1280-1500)",
iface.name
),
remediation_hint: None,
});
}
// Check peer MTU consistency against server MTU
let iface_peers: Vec<_> = peers
.iter()
.filter(|p| p.interface_id == iface.id)
.collect();
for p in iface_peers {
if let Some(peer_mtu) = p.mtu.filter(|&pm| pm > mtu) {
checks.push(DiagnosticCheck {
check_name: format!("peer_mtu_{}", p.name),
status: DiagnosticStatus::Warning,
observed_value: format!("{peer_mtu} bytes"),
expected_value: Some(format!("<= {mtu} bytes")),
diagnostic_message: format!(
"Peer '{}' MTU ({peer_mtu}) exceeds server interface '{}' MTU ({mtu})",
p.name, iface.name
),
remediation_hint: Some(
"Align peer MTU to be equal to or less than server interface MTU"
.to_string(),
),
});
}
}
}
// 2. Client Profile Recommendations Check
checks.push(DiagnosticCheck {
check_name: "client_profile_mobile_recommendation".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "1280 bytes (keepalive: 25s)".to_string(),
expected_value: Some("1280 bytes".to_string()),
diagnostic_message: "Recommended MTU for mobile/cellular connections is 1280 to prevent carrier fragmentation".to_string(),
remediation_hint: None,
});
checks.push(DiagnosticCheck {
check_name: "client_profile_cgnat_recommendation".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "1360 bytes (keepalive: 25s)".to_string(),
expected_value: Some("1360 bytes".to_string()),
diagnostic_message: "Recommended MTU for CGNAT connections is 1360 to accommodate carrier-grade NAT encapsulation".to_string(),
remediation_hint: None,
});
checks.push(DiagnosticCheck {
check_name: "client_profile_wifi_recommendation".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "1420 bytes (keepalive: 25s)".to_string(),
expected_value: Some("1420 bytes".to_string()),
diagnostic_message: "Recommended MTU for standard Wi-Fi and wired connections is 1420 bytes".to_string(),
remediation_hint: None,
});
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "mtu".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// Reconciliation drift diagnostics.
pub async fn diagnose_reconciliation(&self) -> ApiResult<DiagnosticReport> {
let mut checks = Vec::new();
let plan = self.reconciler.plan().await?;
checks.push(DiagnosticCheck {
check_name: "overall_drift".to_string(),
status: if plan.has_drift {
DiagnosticStatus::Warning
} else {
DiagnosticStatus::Pass
},
observed_value: if plan.has_drift {
format!("{} drift actions pending", plan.actions.len())
} else {
"zero_drift".to_string()
},
expected_value: Some("zero_drift".to_string()),
diagnostic_message: if plan.has_drift {
"Discrepancies detected between SQLite desired state and Linux kernel state"
.to_string()
} else {
"SQLite desired state and live kernel state are in full synchronization".to_string()
},
remediation_hint: if plan.has_drift {
Some("Execute 'nx9-wg reconcile apply' to synchronize changes".to_string())
} else {
None
},
});
for action in plan.actions {
checks.push(DiagnosticCheck {
check_name: format!("drift:{}:{}", action.subsystem, action.action_type),
status: DiagnosticStatus::Warning,
observed_value: action.resource_id,
expected_value: None,
diagnostic_message: action.description,
remediation_hint: Some("Run 'nx9-wg reconcile apply'".to_string()),
});
}
let overall = Self::calculate_overall_status(&checks);
Ok(DiagnosticReport {
subsystem: "reconciliation".to_string(),
timestamp: Utc::now().naive_utc(),
overall_status: overall,
checks,
})
}
/// Run full diagnosis across all subsystems.
pub async fn diagnose_all(&self) -> ApiResult<Vec<DiagnosticReport>> {
let mut reports = Vec::new();
reports.push(self.diagnose_system().await?);
reports.push(self.diagnose_network().await?);
reports.push(self.diagnose_wan().await?);
reports.push(self.diagnose_wireguard(None).await?);
reports.push(self.diagnose_routing().await?);
reports.push(self.diagnose_forwarding().await?);
reports.push(self.diagnose_firewall().await?);
reports.push(self.diagnose_nat().await?);
reports.push(self.diagnose_mtu().await?);
reports.push(self.diagnose_reconciliation().await?);
Ok(reports)
}
fn calculate_overall_status(checks: &[DiagnosticCheck]) -> DiagnosticStatus {
if checks.iter().any(|c| c.status == DiagnosticStatus::Fail) {
DiagnosticStatus::Fail
} else if checks.iter().any(|c| c.status == DiagnosticStatus::Warning) {
DiagnosticStatus::Warning
} else {
DiagnosticStatus::Pass
}
}
}
+125
View File
@@ -0,0 +1,125 @@
//! API error types and response structures.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Unified API result type.
pub type ApiResult<T> = std::result::Result<T, ApiError>;
/// API-level errors.
#[derive(Debug, Error)]
pub enum ApiError {
#[error("unauthenticated: {0}")]
Unauthenticated(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("validation error: {0}")]
Validation(String),
#[error("rate limited: {0}")]
RateLimited(String),
#[error("internal server error: {0}")]
Internal(String),
#[error("subsystem unavailable: {0}")]
Unavailable(String),
}
impl From<nx9_wg_db::DbError> for ApiError {
fn from(err: nx9_wg_db::DbError) -> Self {
match err {
nx9_wg_db::DbError::NotFound(msg) => Self::NotFound(msg),
nx9_wg_db::DbError::Conflict(msg) => Self::Conflict(msg),
nx9_wg_db::DbError::ConstraintViolation(msg) => Self::BadRequest(msg),
nx9_wg_db::DbError::Validation(msg) => Self::Validation(msg),
nx9_wg_db::DbError::Sqlx(e) => {
tracing::error!("database error: {e}");
Self::Internal("A database error occurred".to_string())
}
nx9_wg_db::DbError::Migration(msg) => Self::Internal(format!("Migration error: {msg}")),
nx9_wg_db::DbError::Internal(msg) => Self::Internal(msg),
}
}
}
impl From<nx9_wg_core::error::Nx9Error> for ApiError {
fn from(err: nx9_wg_core::error::Nx9Error) -> Self {
match err {
nx9_wg_core::error::Nx9Error::Validation(msg) => Self::Validation(msg),
nx9_wg_core::error::Nx9Error::Auth(msg) => Self::Unauthenticated(msg),
nx9_wg_core::error::Nx9Error::Crypto(msg) => Self::Internal(msg),
nx9_wg_core::error::Nx9Error::Config(msg) => Self::BadRequest(msg),
_ => Self::Internal(err.to_string()),
}
}
}
/// Standard JSON error envelope.
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: ErrorBody,
}
/// Error details in the error response.
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorBody {
pub code: String,
pub message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
Self::Unauthenticated(msg) => {
(StatusCode::UNAUTHORIZED, "UNAUTHENTICATED", msg.clone())
}
Self::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
Self::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
Self::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.clone()),
Self::Validation(msg) => (
StatusCode::UNPROCESSABLE_ENTITY,
"VALIDATION_ERROR",
msg.clone(),
),
Self::RateLimited(msg) => (StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED", msg.clone()),
Self::Internal(msg) => {
tracing::error!("Internal server error: {msg}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"An unexpected error occurred".to_string(),
)
}
Self::Unavailable(msg) => (
StatusCode::SERVICE_UNAVAILABLE,
"SUBSYSTEM_UNAVAILABLE",
msg.clone(),
),
};
let body = Json(ErrorResponse {
error: ErrorBody {
code: code.to_string(),
message,
},
});
(status, body).into_response()
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Axum REST API, WebSocket server, and system services for nx9-wg.
pub mod allocator;
pub mod auth;
pub mod backup;
pub mod diagnostics;
pub mod error;
pub mod profile_resolver;
pub mod reconciliation;
pub mod routes;
pub mod state;
pub use allocator::{IpAllocator, NetworkAllocation};
pub use auth::{
AuthService, AuthenticatedAdmin, BootstrapOptions, BootstrapResult, bootstrap_admin,
};
pub use backup::BackupService;
pub use diagnostics::DiagnosticsService;
pub use error::{ApiError, ApiResult, ErrorBody, ErrorResponse};
pub use profile_resolver::ClientProfileResolver;
pub use reconciliation::{
ReconciliationAction, ReconciliationEngine, ReconciliationPlan, ReconciliationReport,
};
pub use routes::build_api_router;
pub use state::{AppState, SystemEvent};
+411
View File
@@ -0,0 +1,411 @@
//! Native client environment and MTU profile resolution service.
use crate::error::ApiError;
use nx9_wg_core::types::client_profile::{
ClientProfile, ConnectionType, DeviceCategory, NatType, ResolvedClientProfile,
};
use nx9_wg_core::validation::validate_client_mtu;
use nx9_wg_db::Store;
/// Options for resolving a client profile.
#[derive(Debug, Clone, Default)]
pub struct ProfileResolutionOptions<'a> {
pub provider: Option<&'a str>,
pub device: Option<DeviceCategory>,
pub connection: Option<ConnectionType>,
pub nat: Option<NatType>,
pub manual_mtu: Option<u16>,
pub profile_id: Option<&'a str>,
pub server_mtu: Option<u16>,
}
/// Service for deterministic client configuration profile resolution.
#[derive(Debug, Clone, Default)]
pub struct ClientProfileResolver;
impl ClientProfileResolver {
/// Resolve an authoritative `ResolvedClientProfile` with structured options.
pub async fn resolve_opts(
store: &Store,
opts: ProfileResolutionOptions<'_>,
) -> Result<ResolvedClientProfile, ApiError> {
let (base_profile, is_custom_id) = if let Some(p_id) = opts.profile_id {
let p = store
.get_client_profile(p_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("client profile '{p_id}' not found")))?;
(p, true)
} else {
let resolved = Self::match_best_profile(
store,
opts.provider,
opts.device,
opts.connection,
opts.nat,
)
.await?;
(resolved, false)
};
let is_manually_overridden = opts.manual_mtu.is_some();
let final_mtu = if let Some(m) = opts.manual_mtu {
validate_client_mtu(m).map_err(|e| ApiError::Validation(e.to_string()))?
} else {
base_profile.mtu
};
// Construct warnings if MTU might cause path issues
let mut warnings = Vec::new();
if is_manually_overridden {
warnings.push("Client MTU has been manually overridden by administrator.".to_string());
}
if let Some(s_mtu) = opts.server_mtu.filter(|&sm| final_mtu > sm) {
warnings.push(format!(
"Client MTU ({final_mtu}) exceeds server interface MTU ({s_mtu}), which may cause packet truncation or fragmentation."
));
}
if final_mtu > 1500 {
warnings.push(format!(
"Client MTU ({final_mtu}) is in jumbo frame range and may cause drops on standard WAN paths."
));
}
let warning = if warnings.is_empty() {
None
} else {
Some(warnings.join(" "))
};
let resolved_connection = if is_custom_id {
base_profile.connection_type
} else {
opts.connection.unwrap_or(base_profile.connection_type)
};
let resolved_nat = if is_custom_id {
base_profile.nat_type
} else {
opts.nat.unwrap_or(base_profile.nat_type)
};
let resolved_device = if is_custom_id {
base_profile.device
} else {
opts.device.or(base_profile.device)
};
let resolved_provider = if is_custom_id {
base_profile.provider
} else {
opts.provider
.map(|s| s.to_string())
.or(base_profile.provider)
};
Ok(ResolvedClientProfile {
mtu: final_mtu,
persistent_keepalive: base_profile.persistent_keepalive,
dns: base_profile.dns,
is_manually_overridden,
applied_profile_id: base_profile.id,
applied_profile_name: base_profile.name,
connection_type: resolved_connection,
nat_type: resolved_nat,
device: resolved_device,
provider: resolved_provider,
warning,
})
}
/// Convenience wrapper for resolving a client profile.
#[allow(clippy::too_many_arguments)]
pub async fn resolve(
store: &Store,
provider: Option<&str>,
device: Option<DeviceCategory>,
connection: Option<ConnectionType>,
nat: Option<NatType>,
manual_mtu: Option<u16>,
profile_id: Option<&str>,
server_mtu: Option<u16>,
) -> Result<ResolvedClientProfile, ApiError> {
Self::resolve_opts(
store,
ProfileResolutionOptions {
provider,
device,
connection,
nat,
manual_mtu,
profile_id,
server_mtu,
},
)
.await
}
/// Match the best candidate profile from Store using a deterministic specificity score.
async fn match_best_profile(
store: &Store,
provider: Option<&str>,
device: Option<DeviceCategory>,
connection: Option<ConnectionType>,
nat: Option<NatType>,
) -> Result<ClientProfile, ApiError> {
let profiles = store.list_client_profiles().await?;
let mut scored_profiles: Vec<(i32, bool, String, ClientProfile)> = Vec::new();
for p in profiles {
let mut score = 0;
// Provider matching
if let Some(req_p) = provider {
if let Some(ref prof_p) = p.provider {
if prof_p.eq_ignore_ascii_case(req_p) {
score += 100;
} else {
// Specified provider did not match
continue;
}
}
} else if p.provider.is_some() {
// If no provider requested, skip provider-specific profiles
continue;
}
// Device matching
if let Some(req_d) = device {
if let Some(prof_d) = p.device {
if prof_d == req_d {
score += 40;
} else {
continue;
}
}
} else if p.device.is_some() {
// If no device requested, skip device-specific profiles
continue;
}
// Connection matching
if let Some(req_c) = connection {
if p.connection_type == req_c {
score += 20;
} else if p.connection_type == ConnectionType::Other {
score += 5;
} else {
continue;
}
} else if p.connection_type == ConnectionType::Web {
score += 5;
}
// NAT matching
if let Some(req_n) = nat {
if p.nat_type == req_n {
score += 20;
} else if p.nat_type == NatType::Unknown {
score += 5;
} else {
continue;
}
} else if p.nat_type == NatType::Unknown {
score += 5;
}
// Custom profile slight preference
if !p.is_builtin {
score += 2;
}
scored_profiles.push((score, p.is_builtin, p.id.clone(), p));
}
// Sort by: score descending, is_builtin ascending (custom first), ID ascending (deterministic)
scored_profiles.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| a.1.cmp(&b.1))
.then_with(|| a.2.cmp(&b.2))
});
if let Some((_, _, _, best)) = scored_profiles.into_iter().next() {
return Ok(best);
}
// Fallback default profile if database somehow had zero matches
let now = chrono::Utc::now().naive_utc();
Ok(ClientProfile {
id: "default-fallback".to_string(),
name: "Default Fallback".to_string(),
provider: None,
device: None,
connection_type: ConnectionType::Web,
nat_type: NatType::Unknown,
mtu: 1420,
dns: None,
persistent_keepalive: Some(25),
is_builtin: true,
description: Some("Universal fallback client profile".to_string()),
created_at: now,
updated_at: now,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_resolver_matrix() {
let store = Store::connect_in_memory().await.unwrap();
store.migrate().await.unwrap();
// 1. Mobile default
let res = ClientProfileResolver::resolve(
&store,
None,
None,
Some(ConnectionType::Mobile),
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(res.mtu, 1280);
assert_eq!(res.connection_type, ConnectionType::Mobile);
assert!(!res.is_manually_overridden);
// 2. Wi-Fi default
let res = ClientProfileResolver::resolve(
&store,
None,
None,
Some(ConnectionType::Wifi),
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(res.mtu, 1420);
assert_eq!(res.connection_type, ConnectionType::Wifi);
// 3. CGNAT default
let res = ClientProfileResolver::resolve(
&store,
None,
None,
None,
Some(NatType::Cgnat),
None,
None,
None,
)
.await
.unwrap();
assert_eq!(res.mtu, 1360);
assert_eq!(res.nat_type, NatType::Cgnat);
// 4. Android on Mobile
let res = ClientProfileResolver::resolve(
&store,
None,
Some(DeviceCategory::Android),
Some(ConnectionType::Mobile),
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(res.mtu, 1280);
assert_eq!(res.applied_profile_id, "android-mobile");
// 5. Provider specific (Starlink CGNAT)
let res = ClientProfileResolver::resolve(
&store,
Some("starlink"),
None,
None,
Some(NatType::Cgnat),
None,
None,
None,
)
.await
.unwrap();
assert_eq!(res.mtu, 1360);
assert_eq!(res.applied_profile_id, "starlink-cgnat");
// 6. Explicit Profile ID
let res = ClientProfileResolver::resolve(
&store,
None,
None,
None,
None,
None,
Some("default-wifi"),
None,
)
.await
.unwrap();
assert_eq!(res.mtu, 1420);
assert_eq!(res.applied_profile_id, "default-wifi");
// 7. Manual MTU Override
let res = ClientProfileResolver::resolve(
&store,
None,
None,
Some(ConnectionType::Mobile),
None,
Some(1350),
None,
Some(1420),
)
.await
.unwrap();
assert_eq!(res.mtu, 1350);
assert!(res.is_manually_overridden);
assert!(
res.warning
.as_ref()
.unwrap()
.contains("manually overridden")
);
// 8. Server MTU warning
let res = ClientProfileResolver::resolve(
&store,
None,
None,
Some(ConnectionType::Wifi),
None,
Some(1450),
None,
Some(1420),
)
.await
.unwrap();
assert!(
res.warning
.as_ref()
.unwrap()
.contains("exceeds server interface MTU")
);
// 9. Invalid manual MTU rejection
assert!(
ClientProfileResolver::resolve(&store, None, None, None, None, Some(1200), None, None,)
.await
.is_err()
);
}
}
+393
View File
@@ -0,0 +1,393 @@
//! Deterministic reconciliation engine between SQLite desired state and live Linux kernel state.
use crate::error::{ApiError, ApiResult};
use crate::state::{AppState, SystemEvent};
use chrono::Utc;
use nx9_wg_core::types::audit::AuditEventType;
use nx9_wg_core::types::wireguard::PeerState;
use nx9_wg_network::NetworkEngine;
use nx9_wireguard::WireGuardEngine;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
/// Individual action proposed or taken by the reconciler.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReconciliationAction {
pub subsystem: String,
pub resource_id: String,
pub action_type: String,
pub description: String,
}
/// Plan describing detected drift and planned remediation steps.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReconciliationPlan {
pub has_drift: bool,
pub actions: Vec<ReconciliationAction>,
pub interface_changes: usize,
pub peer_changes: usize,
pub route_changes: usize,
pub firewall_changes: usize,
pub forwarding_changes: usize,
}
/// Final report of an executed reconciliation cycle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReconciliationReport {
pub success: bool,
pub executed_actions: usize,
pub details: Vec<String>,
}
/// Reconciliation engine coordinating SQLite store, WireGuard engine, and Network engine.
pub struct ReconciliationEngine {
state: AppState,
wg_engine: Arc<dyn WireGuardEngine>,
net_engine: Arc<dyn NetworkEngine>,
}
impl ReconciliationEngine {
/// Create a new reconciliation engine.
pub fn new(
state: AppState,
wg_engine: Arc<dyn WireGuardEngine>,
net_engine: Arc<dyn NetworkEngine>,
) -> Self {
Self {
state,
wg_engine,
net_engine,
}
}
/// Sweep expired active peers in SQLite and update their state.
pub async fn sweep_expired_peers(&self) -> ApiResult<usize> {
let now = Utc::now().naive_utc();
let expired_peers = self.state.store.get_expired_active_peers(now).await?;
let count = expired_peers.len();
for peer in expired_peers {
self.state.store.mark_peer_expired(peer.id).await?;
let _ = self
.state
.store
.record_audit(
AuditEventType::PeerExpire,
"reconciliation",
Some("peer"),
Some(&peer.id.to_string()),
Some(&format!(
"Peer '{}' reached expiration date and transitioned to expired",
peer.name
)),
None,
None,
)
.await;
self.state.broadcast(SystemEvent::PeerChanged {
id: peer.id.to_string(),
action: "expired".to_string(),
});
}
Ok(count)
}
/// Compute reconciliation plan by comparing desired state against live telemetry.
pub async fn plan(&self) -> ApiResult<ReconciliationPlan> {
// Run expiration sweep first so desired state reflects current time
let _ = self.sweep_expired_peers().await;
let mut plan = ReconciliationPlan::default();
// 1. Interfaces and Peers
let desired_interfaces = self.state.store.list_interfaces().await?;
let live_interfaces = self.wg_engine.list_interfaces().await.map_err(|e| {
ApiError::Internal(format!("Failed to query live WireGuard interfaces: {e}"))
})?;
for iface in &desired_interfaces {
if iface.enabled {
let live_stats = self
.wg_engine
.get_interface_stats(&iface.name)
.await
.map_err(|e| {
ApiError::Internal(format!(
"Failed to get live stats for '{}': {e}",
iface.name
))
})?;
let live_peer_keys: Vec<String> = live_stats
.as_ref()
.map(|s| s.peers.iter().map(|p| p.public_key.clone()).collect())
.unwrap_or_default();
match live_stats.as_ref() {
Some(stats) => {
if stats.public_key != iface.public_key.as_str()
|| stats.listen_port != iface.listen_port
{
plan.actions.push(ReconciliationAction {
subsystem: "wireguard".to_string(),
resource_id: iface.id.to_string(),
action_type: "update_interface".to_string(),
description: format!(
"Interface '{}' configuration drift detected; update listen port / keys",
iface.name
),
});
plan.interface_changes += 1;
}
}
None => {
plan.actions.push(ReconciliationAction {
subsystem: "wireguard".to_string(),
resource_id: iface.id.to_string(),
action_type: "create_interface".to_string(),
description: format!(
"Interface '{}' missing in kernel; create and sync",
iface.name
),
});
plan.interface_changes += 1;
}
}
// Check peers (only Active desired peers should be live)
let desired_peers = self.state.store.list_peers_for_interface(iface.id).await?;
let active_desired_peers: Vec<_> = desired_peers
.iter()
.filter(|p| p.state == PeerState::Active)
.collect();
for p in &active_desired_peers {
if !live_peer_keys.contains(&p.public_key.as_str().to_string()) {
plan.actions.push(ReconciliationAction {
subsystem: "wireguard".to_string(),
resource_id: p.id.to_string(),
action_type: "add_peer".to_string(),
description: format!(
"Peer '{}' ({}) missing in live interface",
p.name,
p.public_key.as_str()
),
});
plan.peer_changes += 1;
}
}
// Check for live peers that are no longer active in database
if let Some(stats) = live_stats.as_ref() {
let active_keys: Vec<String> = active_desired_peers
.iter()
.map(|p| p.public_key.as_str().to_string())
.collect();
for live_p in &stats.peers {
if !active_keys.contains(&live_p.public_key) {
plan.actions.push(ReconciliationAction {
subsystem: "wireguard".to_string(),
resource_id: live_p.public_key.clone(),
action_type: "remove_inactive_peer".to_string(),
description: format!(
"Peer ({}) is inactive/expired/deleted in database but present in kernel",
live_p.public_key
),
});
plan.peer_changes += 1;
}
}
}
} else if live_interfaces.contains(&iface.name) {
plan.actions.push(ReconciliationAction {
subsystem: "wireguard".to_string(),
resource_id: iface.id.to_string(),
action_type: "delete_interface".to_string(),
description: format!(
"Interface '{}' is disabled in database; remove from kernel",
iface.name
),
});
plan.interface_changes += 1;
}
}
// 2. Routes
let desired_routes = self.state.store.list_routes().await?;
let enabled_routes: Vec<_> = desired_routes.iter().filter(|r| r.enabled).collect();
if !enabled_routes.is_empty() {
plan.actions.push(ReconciliationAction {
subsystem: "network".to_string(),
resource_id: "routing_table".to_string(),
action_type: "sync_routes".to_string(),
description: format!(
"Synchronize {} active routes to kernel",
enabled_routes.len()
),
});
plan.route_changes += 1;
}
// 3. Firewall and NAT
let desired_fw_rules = self.state.store.list_firewall_rules().await?;
if !desired_fw_rules.is_empty() {
plan.actions.push(ReconciliationAction {
subsystem: "firewall".to_string(),
resource_id: "nftables".to_string(),
action_type: "sync_nftables".to_string(),
description: format!(
"Synchronize {} firewall rules and NAT table",
desired_fw_rules.len()
),
});
plan.firewall_changes += 1;
}
// 4. IP Forwarding
let fwd_status = self
.net_engine
.get_forwarding_status()
.await
.map_err(|e| ApiError::Internal(format!("Failed to get forwarding status: {e}")))?;
if !fwd_status.ipv4_enabled {
plan.actions.push(ReconciliationAction {
subsystem: "forwarding".to_string(),
resource_id: "ipv4_forward".to_string(),
action_type: "enable_forwarding".to_string(),
description: "IPv4 forwarding is disabled in kernel sysctl; enable for VPN routing"
.to_string(),
});
plan.forwarding_changes += 1;
}
plan.has_drift = !plan.actions.is_empty();
Ok(plan)
}
/// Execute the reconciliation plan, applying changes idempotently to kernel adapters.
pub async fn apply(&self) -> ApiResult<ReconciliationReport> {
// Sweep expired peers
let _ = self.sweep_expired_peers().await;
let desired_interfaces = self.state.store.list_interfaces().await?;
let mut details = Vec::new();
// 1. Sync all active WireGuard interfaces and their peers
let mut wg_subnets = Vec::new();
for iface in &desired_interfaces {
if iface.enabled {
let peers = self.state.store.list_peers_for_interface(iface.id).await?;
self.wg_engine
.sync_interface(iface, &peers)
.await
.map_err(|e| {
ApiError::Internal(format!(
"Failed to sync interface '{}': {e}",
iface.name
))
})?;
wg_subnets.push(iface.address_v4);
if let Some(v6) = iface.address_v6 {
wg_subnets.push(v6);
}
details.push(format!(
"Synchronized interface '{}' with {} peers",
iface.name,
peers.len()
));
} else {
let _ = self.wg_engine.delete_interface(&iface.name).await;
details.push(format!(
"Ensured disabled interface '{}' is down",
iface.name
));
}
}
// 2. Sync Routes
let routes = self.state.store.list_routes().await?;
self.net_engine
.sync_routes(&routes)
.await
.map_err(|e| ApiError::Internal(format!("Failed to sync kernel routes: {e}")))?;
details.push(format!("Synchronized {} routing entries", routes.len()));
// 3. Sync Firewall & NAT with peer IP resolution
let raw_fw_rules = self.state.store.list_firewall_rules().await?;
let mut resolved_fw_rules = Vec::with_capacity(raw_fw_rules.len());
for mut rule in raw_fw_rules {
if let Some(peer_id) = rule.peer_id {
let peer = self.state.store.get_peer(peer_id).await.ok().flatten();
if let Some(addr) = peer
.and_then(|p| p.address_v4)
.filter(|_| rule.source.is_none() && rule.destination.is_none())
{
rule.source = Some(addr.addr().to_string());
}
}
resolved_fw_rules.push(rule);
}
let enable_nat = self
.state
.store
.get_setting("enable_nat")
.await?
.map(|s| s.value == "true" || s.value == "1")
.unwrap_or(true);
self.net_engine
.sync_firewall(&resolved_fw_rules, enable_nat, &wg_subnets)
.await
.map_err(|e| ApiError::Internal(format!("Failed to sync nftables firewall: {e}")))?;
details.push(format!(
"Synchronized {} firewall rules into table inet nx9_wg (NAT: {enable_nat})",
resolved_fw_rules.len()
));
// 4. Audit reconciliation run
let _ = self
.state
.store
.record_audit(
AuditEventType::ReconciliationRun,
"system",
Some("reconciliation"),
None,
Some(&format!("Reconciliation applied {} actions", details.len())),
None,
None,
)
.await;
self.state.broadcast(SystemEvent::AuditEvent {
event_type: AuditEventType::ReconciliationRun,
message: Some(format!("Reconciliation applied {} actions", details.len())),
resource_type: Some("reconciliation".to_string()),
resource_id: None,
});
Ok(ReconciliationReport {
success: true,
executed_actions: details.len(),
details,
})
}
/// Background reconciliation loop running on a fixed interval.
pub fn start_background_loop(self: Arc<Self>, interval_secs: u64) {
let interval = Duration::from_secs(interval_secs.max(1));
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
if let Err(e) = self.apply().await {
tracing::error!("Periodic reconciliation cycle failed: {e}");
}
}
});
}
}
File diff suppressed because it is too large. Load diff
+127
View File
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>nx9-wg — Native WireGuard Appliance</title>
<meta name="description" content="Production Native Rust WireGuard Management Appliance for Linux">
<style>
/* STYLE_PLACEHOLDER */
</style>
</head>
<body>
<div id="app-layout">
<!-- Top Application Bar -->
<header class="topbar">
<div class="topbar-left">
<button id="menu-toggle-btn" class="menu-toggle-btn" aria-label="Toggle navigation drawer" onclick="toggleSidebar()">☰</button>
<div class="brand-logo">
<span class="brand-mark">NX9</span>
<span>nx9-wg</span>
</div>
</div>
<div class="topbar-right">
<div class="ws-indicator" id="ws-indicator" title="WebSocket Live Event Stream">
<span id="ws-dot">●</span> <span id="ws-label" style="display:none;">Live</span>
</div>
<div class="system-status-indicator" id="global-status-badge">
<span>✓</span> <span>Operational</span>
</div>
<div class="admin-badge">
<span>👤</span> <span id="admin-user-label">admin</span>
</div>
<button class="theme-toggle-btn" onclick="toggleTheme()" title="Toggle Dark/Light theme" aria-label="Toggle theme">
<span id="theme-icon">🌙</span>
</button>
<button class="btn btn-secondary btn-sm" onclick="navigateTo('settings')" title="Settings">⚙️</button>
<button class="btn btn-danger btn-sm" onclick="handleLogout()" title="Logout">Logout</button>
</div>
</header>
<div class="main-container">
<!-- Mobile Backdrop -->
<div id="mobile-overlay" class="mobile-overlay" onclick="closeSidebar()"></div>
<!-- Navigation Sidebar -->
<nav id="sidebar" class="sidebar">
<!-- Primary Navigation -->
<div class="sidebar-section">
<div class="sidebar-section-title">Primary</div>
<a href="#dashboard" class="nav-link active" onclick="navigateTo('dashboard')">
<span class="nav-icon">📊</span> Dashboard
</a>
<a href="#interfaces" class="nav-link" onclick="navigateTo('interfaces')">
<span class="nav-icon">🔌</span> Interfaces
</a>
<a href="#peers" class="nav-link" onclick="navigateTo('peers')">
<span class="nav-icon">📱</span> Peers
</a>
<a href="#networks" class="nav-link" onclick="navigateTo('networks')">
<span class="nav-icon">🌐</span> Networks
</a>
<a href="#routes" class="nav-link" onclick="navigateTo('routes')">
<span class="nav-icon">🛣️</span> Routes
</a>
<a href="#firewall" class="nav-link" onclick="navigateTo('firewall')">
<span class="nav-icon">🛡️</span> Firewall
</a>
<a href="#nat" class="nav-link" onclick="navigateTo('nat')">
<span class="nav-icon">🔀</span> NAT & Masquerade
</a>
<a href="#forwarding" class="nav-link" onclick="navigateTo('forwarding')">
<span class="nav-icon">⚡</span> IP Forwarding
</a>
</div>
<!-- Operations Navigation -->
<div class="sidebar-section">
<div class="sidebar-section-title">Operations</div>
<a href="#reconciliation" class="nav-link" onclick="navigateTo('reconciliation')">
<span class="nav-icon">🔄</span> Reconciliation
</a>
<a href="#diagnostics" class="nav-link" onclick="navigateTo('diagnostics')">
<span class="nav-icon">🩺</span> Diagnostics
</a>
<a href="#live-state" class="nav-link" onclick="navigateTo('live-state')">
<span class="nav-icon">📡</span> Live State
</a>
</div>
<!-- Administration Navigation -->
<div class="sidebar-section">
<div class="sidebar-section-title">Administration</div>
<a href="#settings" class="nav-link" onclick="navigateTo('settings')">
<span class="nav-icon">⚙️</span> Settings
</a>
<a href="#backups" class="nav-link" onclick="navigateTo('backups')">
<span class="nav-icon">💾</span> Backups
</a>
<a href="#audit" class="nav-link" onclick="navigateTo('audit')">
<span class="nav-icon">📜</span> Audit Log
</a>
<a href="#administrator" class="nav-link" onclick="navigateTo('administrator')">
<span class="nav-icon">👤</span> Administrator
</a>
</div>
</nav>
<!-- Main Content Container -->
<main id="main-content" class="content-wrapper">
<div id="page-container">
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
Loading nx9-wg native appliance...
</div>
</div>
</main>
</div>
</div>
<!-- Global Modal Container -->
<div id="modal-root" style="display: none;"></div>
<!-- Embedded Client Logic -->
<script>
/* SCRIPT_PLACEHOLDER */
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
//! Audit log query HTTP handler.
use crate::error::ApiResult;
use crate::state::AppState;
use axum::Json;
use axum::extract::{Query, State};
use nx9_wg_core::types::audit::{AuditEvent, AuditEventType};
use nx9_wg_db::AuditFilter;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct AuditQueryParams {
pub event_type: Option<AuditEventType>,
pub resource_type: Option<String>,
pub resource_id: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct AuditQueryResponse {
pub total: i64,
pub limit: u32,
pub offset: u32,
pub events: Vec<AuditEvent>,
}
/// GET /api/v1/audit
pub async fn list_audit_events_handler(
State(state): State<AppState>,
Query(params): Query<AuditQueryParams>,
) -> ApiResult<Json<AuditQueryResponse>> {
let limit = params.limit.unwrap_or(50).min(500);
let offset = params.offset.unwrap_or(0);
let filter = AuditFilter {
event_type: params.event_type,
resource_type: params.resource_type,
resource_id: params.resource_id,
since: None,
until: None,
};
let total = state.store.count_audit_events(&filter).await?;
let events = state
.store
.list_audit_events(&filter, limit, offset)
.await?;
Ok(Json(AuditQueryResponse {
total,
limit,
offset,
events,
}))
}
+199
View File
@@ -0,0 +1,199 @@
//! Authentication and session management HTTP handlers.
use crate::auth::middleware::AuthenticatedAdmin;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::http::header::SET_COOKIE;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub session_id: String,
pub expires_at: NaiveDateTime,
}
#[derive(Debug, Deserialize)]
pub struct ChangePasswordRequest {
pub current_password: Option<String>,
pub new_password: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateTokenRequest {
pub name: String,
pub expires_at: Option<NaiveDateTime>,
}
#[derive(Debug, Serialize)]
pub struct CreateTokenResponse {
pub token: nx9_wg_core::types::auth::ApiToken,
pub raw_token: String,
}
#[derive(Debug, Serialize)]
pub struct SessionResponse {
pub username: String,
pub session_id: Option<String>,
pub token_id: Option<String>,
pub totp_enabled: bool,
pub last_login_at: Option<NaiveDateTime>,
}
#[derive(Debug, Serialize)]
pub struct GenericSuccess {
pub success: bool,
pub message: String,
}
/// POST /api/v1/auth/login
pub async fn login_handler(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> ApiResult<Response> {
let session = state
.auth
.login(&payload.username, &payload.password, None, None)
.await?;
let cookie_val = format!(
"nx9_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
session.id,
24 * 3600
);
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
cookie_val
.parse()
.map_err(|e| ApiError::Internal(format!("Failed to build cookie header: {e}")))?,
);
let resp_body = Json(LoginResponse {
session_id: session.id,
expires_at: session.expires_at,
});
Ok((headers, resp_body).into_response())
}
/// POST /api/v1/auth/logout
pub async fn logout_handler(
State(state): State<AppState>,
Extension(auth_user): Extension<AuthenticatedAdmin>,
) -> ApiResult<Response> {
if let Some(ref session_id) = auth_user.session_id {
state.auth.logout(session_id, None).await?;
}
let cookie_val = "nx9_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
cookie_val
.parse()
.map_err(|e| ApiError::Internal(format!("Failed to build cookie header: {e}")))?,
);
let resp_body = Json(GenericSuccess {
success: true,
message: "Logged out successfully".to_string(),
});
Ok((headers, resp_body).into_response())
}
/// GET /api/v1/auth/session
pub async fn session_handler(
State(state): State<AppState>,
Extension(auth_user): Extension<AuthenticatedAdmin>,
) -> ApiResult<Json<SessionResponse>> {
let admin = state
.store
.get_admin()
.await?
.ok_or_else(|| ApiError::NotFound("Administrator not found".to_string()))?;
Ok(Json(SessionResponse {
username: auth_user.username,
session_id: auth_user.session_id,
token_id: auth_user.token_id,
totp_enabled: admin.totp_enabled,
last_login_at: admin.last_login_at,
}))
}
/// POST /api/v1/auth/password
pub async fn change_password_handler(
State(state): State<AppState>,
Extension(_auth_user): Extension<AuthenticatedAdmin>,
Json(payload): Json<ChangePasswordRequest>,
) -> ApiResult<Json<GenericSuccess>> {
// If current password was supplied, verify it first
if let Some(ref cur_pw) = payload.current_password {
let admin = state
.store
.get_admin()
.await?
.ok_or_else(|| ApiError::NotFound("Administrator not found".to_string()))?;
if !nx9_wg_core::crypto::verify_password(cur_pw, &admin.password_hash)? {
return Err(ApiError::Unauthenticated(
"Current password does not match".to_string(),
));
}
}
state
.auth
.change_password(&payload.new_password, None)
.await?;
Ok(Json(GenericSuccess {
success: true,
message: "Password changed successfully. All active sessions invalidated.".to_string(),
}))
}
/// POST /api/v1/auth/tokens
pub async fn create_token_handler(
State(state): State<AppState>,
Json(payload): Json<CreateTokenRequest>,
) -> ApiResult<Json<CreateTokenResponse>> {
let (token, raw_token) = state
.auth
.create_api_token(&payload.name, payload.expires_at, None)
.await?;
Ok(Json(CreateTokenResponse { token, raw_token }))
}
/// GET /api/v1/auth/tokens
pub async fn list_tokens_handler(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<nx9_wg_core::types::auth::ApiToken>>> {
let tokens = state.store.list_tokens().await?;
Ok(Json(tokens))
}
/// DELETE /api/v1/auth/tokens/{id}
pub async fn revoke_token_handler(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<GenericSuccess>> {
state.auth.revoke_api_token(&id, None).await?;
Ok(Json(GenericSuccess {
success: true,
message: format!("API token '{id}' revoked successfully"),
}))
}
+185
View File
@@ -0,0 +1,185 @@
//! Backup metadata, creation, verification, and restore HTTP handlers.
use crate::auth::middleware::AuthenticatedAdmin;
use crate::backup::BackupService;
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::AppState;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use chrono::Utc;
use nx9_wg_core::types::backup::BackupMeta;
use serde::Deserialize;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateBackupRecordRequest {
pub filename: String,
pub size_bytes: i64,
pub checksum: String,
pub schema_version: String,
pub encrypted: Option<bool>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TriggerBackupRequest {
pub description: Option<String>,
}
/// GET /api/v1/backups
pub async fn list_backups_handler(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<BackupMeta>>> {
let list = state.store.list_backups().await?;
Ok(Json(list))
}
/// POST /api/v1/backups
pub async fn create_backup_record_handler(
State(state): State<AppState>,
Json(payload): Json<CreateBackupRecordRequest>,
) -> ApiResult<Json<BackupMeta>> {
let now = Utc::now().naive_utc();
let meta = BackupMeta {
id: Uuid::new_v4(),
filename: payload.filename,
size_bytes: payload.size_bytes,
checksum: payload.checksum,
schema_version: payload.schema_version,
encrypted: payload.encrypted.unwrap_or(false),
description: payload.description,
created_at: now,
};
state.store.create_backup_meta(&meta).await?;
Ok(Json(meta))
}
/// POST /api/v1/backups/create
pub async fn trigger_backup_handler(
State(state): State<AppState>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<TriggerBackupRequest>,
) -> ApiResult<Json<BackupMeta>> {
let backup_dir = PathBuf::from("backups");
let (meta, _path) = BackupService::create_backup(
&state.store,
&backup_dir,
payload.description.as_deref(),
&admin.username,
None,
)
.await?;
Ok(Json(meta))
}
/// GET /api/v1/backups/{id}
pub async fn get_backup_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<BackupMeta>> {
let meta = state
.store
.get_backup_meta(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Backup record '{id}' not found")))?;
Ok(Json(meta))
}
/// GET /api/v1/backups/{id}/download
pub async fn download_backup_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Response> {
let meta = state
.store
.get_backup_meta(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Backup record '{id}' not found")))?;
let backup_dir = PathBuf::from("backups");
let file_path = backup_dir.join(&meta.filename);
if !file_path.exists() {
return Err(ApiError::NotFound(format!(
"Backup archive file '{}' not found on disk",
meta.filename
)));
}
let bytes = std::fs::read(&file_path)
.map_err(|e| ApiError::Internal(format!("Failed to read backup file for download: {e}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/octet-stream".parse().unwrap());
headers.insert(
CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", meta.filename)
.parse()
.unwrap(),
);
Ok((headers, Body::from(bytes)).into_response())
}
/// DELETE /api/v1/backups/{id}
pub async fn delete_backup_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
let meta = state.store.get_backup_meta(id).await?;
if let Some(m) = meta {
let backup_dir = PathBuf::from("backups");
let file_path = backup_dir.join(&m.filename);
let _ = std::fs::remove_file(file_path);
}
state.store.delete_backup_meta(id).await?;
Ok(Json(GenericSuccess {
success: true,
message: format!("Backup record '{id}' deleted"),
}))
}
/// POST /api/v1/backups/{id}/restore
pub async fn restore_backup_handler(
State(state): State<AppState>,
Extension(admin): Extension<AuthenticatedAdmin>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
let meta = state
.store
.get_backup_meta(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Backup record '{id}' not found")))?;
let backup_dir = PathBuf::from("backups");
let file_path = backup_dir.join(&meta.filename);
let active_db = PathBuf::from("nx9-wg.db");
let safety_dir = backup_dir.join("safety");
BackupService::restore_backup(
&state.store,
&file_path,
&active_db,
&safety_dir,
&admin.username,
None,
)
.await?;
Ok(Json(GenericSuccess {
success: true,
message: format!(
"Database successfully restored from backup '{}'",
meta.filename
),
}))
}
@@ -0,0 +1,164 @@
//! Client profiles REST API route handlers.
use crate::error::{ApiError, ApiResult};
use crate::profile_resolver::ClientProfileResolver;
use crate::state::AppState;
use axum::Json;
use axum::extract::{Path, Query, State};
use nx9_wg_core::types::client_profile::{
ClientProfile, ConnectionType, DeviceCategory, NatType, ResolvedClientProfile,
};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Deserialize)]
pub struct ListProfilesQuery {
pub provider: Option<String>,
pub device: Option<String>,
pub connection: Option<String>,
pub nat: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ResolveProfileRequest {
pub provider: Option<String>,
pub device: Option<String>,
pub connection: Option<String>,
pub nat: Option<String>,
pub mtu: Option<u16>,
pub profile: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DeviceCategoryInfo {
pub value: String,
pub label: String,
}
/// GET /api/v1/client-profiles
pub async fn list_client_profiles_handler(
State(state): State<AppState>,
Query(query): Query<ListProfilesQuery>,
) -> ApiResult<Json<Vec<ClientProfile>>> {
let provider = query.provider.as_deref();
let device = query
.device
.as_deref()
.map(DeviceCategory::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let connection = query
.connection
.as_deref()
.map(ConnectionType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let nat = query
.nat
.as_deref()
.map(NatType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let profiles =
if provider.is_some() || device.is_some() || connection.is_some() || nat.is_some() {
state
.store
.find_matching_client_profiles(provider, device, connection, nat)
.await?
} else {
state.store.list_client_profiles().await?
};
Ok(Json(profiles))
}
/// GET /api/v1/client-profiles/providers
pub async fn list_providers_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<String>>> {
let providers = state.store.list_distinct_providers().await?;
Ok(Json(providers))
}
/// GET /api/v1/client-profiles/devices
pub async fn list_devices_handler() -> ApiResult<Json<Vec<DeviceCategoryInfo>>> {
let devices = vec![
DeviceCategoryInfo {
value: "android".to_string(),
label: "Android".to_string(),
},
DeviceCategoryInfo {
value: "ios".to_string(),
label: "iOS".to_string(),
},
DeviceCategoryInfo {
value: "linux".to_string(),
label: "Linux".to_string(),
},
DeviceCategoryInfo {
value: "windows".to_string(),
label: "Windows".to_string(),
},
DeviceCategoryInfo {
value: "macos".to_string(),
label: "macOS".to_string(),
},
DeviceCategoryInfo {
value: "other".to_string(),
label: "Other".to_string(),
},
];
Ok(Json(devices))
}
/// GET /api/v1/client-profiles/{id}
pub async fn get_client_profile_handler(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<ClientProfile>> {
let profile = state
.store
.get_client_profile(&id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("client profile '{id}' not found")))?;
Ok(Json(profile))
}
/// POST /api/v1/client-profiles/resolve
pub async fn resolve_client_profile_handler(
State(state): State<AppState>,
Json(payload): Json<ResolveProfileRequest>,
) -> ApiResult<Json<ResolvedClientProfile>> {
let device = payload
.device
.as_deref()
.map(DeviceCategory::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let connection = payload
.connection
.as_deref()
.map(ConnectionType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let nat = payload
.nat
.as_deref()
.map(NatType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let resolved = ClientProfileResolver::resolve(
&state.store,
payload.provider.as_deref(),
device,
connection,
nat,
payload.mtu,
payload.profile.as_deref(),
None,
)
.await?;
Ok(Json(resolved))
}
@@ -0,0 +1,57 @@
//! Diagnostics HTTP handlers.
use crate::diagnostics::DiagnosticsService;
use crate::error::{ApiError, ApiResult};
use crate::reconciliation::ReconciliationEngine;
use crate::state::AppState;
use axum::Json;
use axum::extract::{Path, Query, State};
use nx9_wg_core::types::diagnostics::{DiagnosticReport, DiagnosticSubsystem};
use nx9_wg_network::NativeLinuxNetworkEngine;
use nx9_wireguard::NativeLinuxWireGuardEngine;
use serde::Deserialize;
use std::str::FromStr;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct DiagnosticQuery {
pub peer_id: Option<Uuid>,
}
/// GET /api/v1/diagnostics/all
pub async fn diagnose_all_handler(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<DiagnosticReport>>> {
let wg_engine = Arc::new(NativeLinuxWireGuardEngine::new());
let net_engine = Arc::new(NativeLinuxNetworkEngine::new());
let reconciler = Arc::new(ReconciliationEngine::new(
state.clone(),
wg_engine.clone(),
net_engine.clone(),
));
let service = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
let reports = service.diagnose_all().await?;
Ok(Json(reports))
}
/// GET /api/v1/diagnostics/{subsystem}
pub async fn diagnose_subsystem_handler(
State(state): State<AppState>,
Path(subsystem_str): Path<String>,
Query(query): Query<DiagnosticQuery>,
) -> ApiResult<Json<Vec<DiagnosticReport>>> {
let subsystem = DiagnosticSubsystem::from_str(&subsystem_str)
.map_err(|e| ApiError::Validation(e.to_string()))?;
let wg_engine = Arc::new(NativeLinuxWireGuardEngine::new());
let net_engine = Arc::new(NativeLinuxNetworkEngine::new());
let reconciler = Arc::new(ReconciliationEngine::new(
state.clone(),
wg_engine.clone(),
net_engine.clone(),
));
let service = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
let reports = service.run_diagnostic(subsystem, query.peer_id).await?;
Ok(Json(reports))
}
+227
View File
@@ -0,0 +1,227 @@
//! Firewall Rule HTTP handlers.
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::AppState;
use axum::Json;
use axum::extract::{Path, Query, State};
use chrono::Utc;
use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::validation::validate_port_spec;
use serde::Deserialize;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct ListFirewallQuery {
pub interface_id: Option<Uuid>,
pub peer_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct CreateFirewallRuleRequest {
pub name: String,
pub interface_id: Option<Uuid>,
pub peer_id: Option<Uuid>,
pub direction: Option<FirewallDirection>,
pub action: Option<FirewallAction>,
pub protocol: Option<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: Option<i32>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateFirewallRuleRequest {
pub name: Option<String>,
pub interface_id: Option<Uuid>,
pub peer_id: Option<Uuid>,
pub direction: Option<FirewallDirection>,
pub action: Option<FirewallAction>,
pub protocol: Option<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: Option<i32>,
pub enabled: Option<bool>,
pub description: Option<String>,
}
/// GET /api/v1/firewall/rules
pub async fn list_firewall_rules_handler(
State(state): State<AppState>,
Query(query): Query<ListFirewallQuery>,
) -> ApiResult<Json<Vec<FirewallRule>>> {
let list = if let Some(peer_id) = query.peer_id {
state.store.list_firewall_rules_for_peer(peer_id).await?
} else if let Some(iface_id) = query.interface_id {
state
.store
.list_firewall_rules_for_interface(iface_id)
.await?
} else {
state.store.list_firewall_rules().await?
};
Ok(Json(list))
}
/// POST /api/v1/firewall/rules
pub async fn create_firewall_rule_handler(
State(state): State<AppState>,
Json(payload): Json<CreateFirewallRuleRequest>,
) -> ApiResult<Json<FirewallRule>> {
if payload.name.trim().is_empty() {
return Err(ApiError::Validation(
"Rule name cannot be empty".to_string(),
));
}
if let Some(ref pr) = payload.port_range {
validate_port_spec(pr).map_err(|e| ApiError::Validation(e.to_string()))?;
}
let now = Utc::now().naive_utc();
let rule = FirewallRule {
id: Uuid::new_v4(),
name: payload.name,
interface_id: payload.interface_id,
peer_id: payload.peer_id,
direction: payload.direction.unwrap_or(FirewallDirection::In),
action: payload.action.unwrap_or(FirewallAction::Accept),
protocol: payload.protocol.unwrap_or(FirewallProtocol::Any),
source: payload.source,
destination: payload.destination,
source_port: payload.source_port,
destination_port: payload.destination_port,
port_range: payload.port_range,
priority: payload.priority.unwrap_or(100),
enabled: true,
description: payload.description,
created_at: now,
updated_at: now,
};
state.store.create_firewall_rule(&rule).await?;
Ok(Json(rule))
}
/// GET /api/v1/firewall/rules/{id}
pub async fn get_firewall_rule_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<FirewallRule>> {
let rule = state
.store
.get_firewall_rule(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Firewall rule '{id}' not found")))?;
Ok(Json(rule))
}
/// PUT /api/v1/firewall/rules/{id}
pub async fn update_firewall_rule_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateFirewallRuleRequest>,
) -> ApiResult<Json<FirewallRule>> {
let mut rule = state
.store
.get_firewall_rule(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Firewall rule '{id}' not found")))?;
if let Some(ref name) = payload.name {
if name.trim().is_empty() {
return Err(ApiError::Validation(
"Rule name cannot be empty".to_string(),
));
}
rule.name = name.clone();
}
if payload.interface_id.is_some() {
rule.interface_id = payload.interface_id;
}
if payload.peer_id.is_some() {
rule.peer_id = payload.peer_id;
}
if let Some(dir) = payload.direction {
rule.direction = dir;
}
if let Some(act) = payload.action {
rule.action = act;
}
if let Some(proto) = payload.protocol {
rule.protocol = proto;
}
if payload.source.is_some() {
rule.source = payload.source;
}
if payload.destination.is_some() {
rule.destination = payload.destination;
}
if payload.source_port.is_some() {
rule.source_port = payload.source_port;
}
if payload.destination_port.is_some() {
rule.destination_port = payload.destination_port;
}
if let Some(ref pr) = payload.port_range {
validate_port_spec(pr).map_err(|e| ApiError::Validation(e.to_string()))?;
rule.port_range = Some(pr.clone());
}
if let Some(prio) = payload.priority {
rule.priority = prio;
}
if let Some(enabled) = payload.enabled {
rule.enabled = enabled;
}
if payload.description.is_some() {
rule.description = payload.description;
}
state.store.update_firewall_rule(&rule).await?;
Ok(Json(rule))
}
/// DELETE /api/v1/firewall/rules/{id}
pub async fn delete_firewall_rule_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.delete_firewall_rule(id).await?;
Ok(Json(GenericSuccess {
success: true,
message: format!("Firewall rule '{id}' deleted"),
}))
}
/// POST /api/v1/firewall/rules/{id}/enable
pub async fn enable_firewall_rule_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.set_firewall_rule_enabled(id, true).await?;
Ok(Json(GenericSuccess {
success: true,
message: format!("Firewall rule '{id}' enabled"),
}))
}
/// POST /api/v1/firewall/rules/{id}/disable
pub async fn disable_firewall_rule_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.set_firewall_rule_enabled(id, false).await?;
Ok(Json(GenericSuccess {
success: true,
message: format!("Firewall rule '{id}' disabled"),
}))
}
+266
View File
@@ -0,0 +1,266 @@
//! WireGuard Interface HTTP handlers.
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::{AppState, SystemEvent};
use axum::Json;
use axum::extract::{Path, State};
use chrono::Utc;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
use nx9_wg_core::validation::{
validate_cidr, validate_interface_name, validate_listen_port, validate_mtu,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateInterfaceRequest {
pub name: String,
pub listen_port: Option<u16>,
pub address_v4: String,
pub address_v6: Option<String>,
pub mtu: Option<u16>,
pub dns: Option<String>,
pub private_key: Option<String>,
pub public_key: Option<String>,
pub pre_up: Option<String>,
pub post_up: Option<String>,
pub pre_down: Option<String>,
pub post_down: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateInterfaceRequest {
pub name: Option<String>,
pub listen_port: Option<u16>,
pub address_v4: Option<String>,
pub address_v6: Option<String>,
pub mtu: Option<u16>,
pub dns: Option<String>,
pub pre_up: Option<String>,
pub post_up: Option<String>,
pub pre_down: Option<String>,
pub post_down: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct InterfaceStatusResponse {
pub interface: Interface,
pub peer_count: usize,
pub active_peer_count: usize,
}
/// GET /api/v1/interfaces
pub async fn list_interfaces_handler(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<Interface>>> {
let list = state.store.list_interfaces().await?;
Ok(Json(list))
}
/// POST /api/v1/interfaces
pub async fn create_interface_handler(
State(state): State<AppState>,
Json(payload): Json<CreateInterfaceRequest>,
) -> ApiResult<Json<Interface>> {
validate_interface_name(&payload.name)?;
let address_v4 = validate_cidr(&payload.address_v4)?;
let address_v6 = match payload.address_v6.as_deref() {
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
_ => None,
};
let listen_port = match payload.listen_port {
Some(p) => validate_listen_port(p)?,
None => 51820,
};
if let Some(m) = payload.mtu {
validate_mtu(m)?;
}
let (priv_k, pub_k) = match (payload.private_key, payload.public_key) {
(Some(priv_s), Some(pub_s)) => (
WireGuardPrivateKey::new(priv_s),
WireGuardPublicKey::new(pub_s),
),
_ => generate_keypair(),
};
let now = Utc::now().naive_utc();
let iface = Interface {
id: Uuid::new_v4(),
name: payload.name,
private_key: priv_k,
public_key: pub_k,
listen_port,
address_v4,
address_v6,
mtu: payload.mtu,
dns: payload.dns,
enabled: true,
pre_up: payload.pre_up,
post_up: payload.post_up,
pre_down: payload.pre_down,
post_down: payload.post_down,
created_at: now,
updated_at: now,
};
state.store.create_interface(&iface).await?;
state.broadcast(SystemEvent::InterfaceChanged {
id: iface.id.to_string(),
action: "created".to_string(),
});
Ok(Json(iface))
}
/// GET /api/v1/interfaces/{id}
pub async fn get_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Interface>> {
let iface = state
.store
.get_interface(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
Ok(Json(iface))
}
/// PUT /api/v1/interfaces/{id}
pub async fn update_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateInterfaceRequest>,
) -> ApiResult<Json<Interface>> {
let mut iface = state
.store
.get_interface(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
if let Some(ref name) = payload.name {
validate_interface_name(name)?;
iface.name = name.clone();
}
if let Some(port) = payload.listen_port {
validate_listen_port(port)?;
iface.listen_port = port;
}
if let Some(ref v4) = payload.address_v4 {
iface.address_v4 = validate_cidr(v4)?;
}
if let Some(ref v6) = payload.address_v6 {
iface.address_v6 = Some(validate_cidr(v6)?);
}
if let Some(m) = payload.mtu {
validate_mtu(m)?;
iface.mtu = Some(m);
}
if let Some(ref dns) = payload.dns {
iface.dns = Some(dns.clone());
}
if payload.pre_up.is_some() {
iface.pre_up = payload.pre_up;
}
if payload.post_up.is_some() {
iface.post_up = payload.post_up;
}
if payload.pre_down.is_some() {
iface.pre_down = payload.pre_down;
}
if payload.post_down.is_some() {
iface.post_down = payload.post_down;
}
state.store.update_interface(&iface).await?;
state.broadcast(SystemEvent::InterfaceChanged {
id: iface.id.to_string(),
action: "updated".to_string(),
});
Ok(Json(iface))
}
/// DELETE /api/v1/interfaces/{id}
pub async fn delete_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.delete_interface(id).await?;
state.broadcast(SystemEvent::InterfaceChanged {
id: id.to_string(),
action: "deleted".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Interface '{id}' and all associated peers deleted"),
}))
}
/// POST /api/v1/interfaces/{id}/enable
pub async fn enable_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.set_interface_enabled(id, true).await?;
state.broadcast(SystemEvent::InterfaceChanged {
id: id.to_string(),
action: "enabled".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Interface '{id}' enabled"),
}))
}
/// POST /api/v1/interfaces/{id}/disable
pub async fn disable_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.set_interface_enabled(id, false).await?;
state.broadcast(SystemEvent::InterfaceChanged {
id: id.to_string(),
action: "disabled".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Interface '{id}' disabled"),
}))
}
/// GET /api/v1/interfaces/{id}/status
pub async fn interface_status_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<InterfaceStatusResponse>> {
let iface = state
.store
.get_interface(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
let peers = state.store.list_peers_for_interface(id).await?;
let active_count = peers
.iter()
.filter(|p| p.state == nx9_wg_core::types::wireguard::PeerState::Active)
.count();
Ok(Json(InterfaceStatusResponse {
interface: iface,
peer_count: peers.len(),
active_peer_count: active_count,
}))
}
+210
View File
@@ -0,0 +1,210 @@
//! Router assembly and route module declarations.
pub mod audit;
pub mod auth;
pub mod backups;
pub mod client_profiles;
pub mod diagnostics;
pub mod firewall;
pub mod interfaces;
pub mod networks;
pub mod peers;
pub mod reconcile;
pub mod routing;
pub mod system;
pub mod ui;
pub mod ws;
use crate::auth::middleware::require_auth;
use crate::state::AppState;
use axum::Router;
use axum::middleware::from_fn_with_state;
use axum::routing::{delete, get, post, put};
use tower_http::compression::CompressionLayer;
use tower_http::trace::TraceLayer;
/// Build the complete Axum API Router with all public, protected, and UI routes.
pub fn build_api_router(state: AppState) -> Router {
// 1. Protected routes (require authenticated admin via session or token)
let protected_router = Router::new()
// Auth management
.route("/auth/logout", post(auth::logout_handler))
.route("/auth/session", get(auth::session_handler))
.route("/auth/password", post(auth::change_password_handler))
.route("/auth/tokens", post(auth::create_token_handler))
.route("/auth/tokens", get(auth::list_tokens_handler))
.route("/auth/tokens/{id}", delete(auth::revoke_token_handler))
// System
.route("/system", get(system::system_overview_handler))
.route("/system/settings", get(system::list_settings_handler))
.route("/system/settings", put(system::upsert_setting_handler))
// Interfaces
.route("/interfaces", get(interfaces::list_interfaces_handler))
.route("/interfaces", post(interfaces::create_interface_handler))
.route("/interfaces/{id}", get(interfaces::get_interface_handler))
.route(
"/interfaces/{id}",
put(interfaces::update_interface_handler),
)
.route(
"/interfaces/{id}",
delete(interfaces::delete_interface_handler),
)
.route(
"/interfaces/{id}/enable",
post(interfaces::enable_interface_handler),
)
.route(
"/interfaces/{id}/disable",
post(interfaces::disable_interface_handler),
)
.route(
"/interfaces/{id}/status",
get(interfaces::interface_status_handler),
)
.route(
"/interfaces/{id}/peers",
get(peers::list_peers_for_interface_handler),
)
.route("/interfaces/{id}/peers", post(peers::create_peer_handler))
// Peers
.route("/peers/{id}", get(peers::get_peer_handler))
.route("/peers/{id}", put(peers::update_peer_handler))
.route("/peers/{id}", delete(peers::delete_peer_handler))
.route("/peers/{id}/enable", post(peers::enable_peer_handler))
.route("/peers/{id}/disable", post(peers::disable_peer_handler))
.route("/peers/{id}/revoke", post(peers::revoke_peer_handler))
.route("/peers/{id}/expire", post(peers::expire_peer_handler))
.route(
"/peers/{id}/lifecycle",
get(peers::get_peer_lifecycle_handler),
)
.route(
"/peers/{id}/config",
get(peers::download_peer_config_handler),
)
.route("/peers/{id}/qr", get(peers::get_peer_qr_handler))
// Networks
.route("/networks", get(networks::list_networks_handler))
.route("/networks", post(networks::create_network_handler))
.route("/networks/{id}", get(networks::get_network_handler))
.route("/networks/{id}", put(networks::update_network_handler))
.route("/networks/{id}", delete(networks::delete_network_handler))
.route(
"/networks/{id}/available",
get(networks::list_available_ips_handler),
)
.route(
"/networks/{id}/allocations",
get(networks::list_allocations_handler),
)
// Routes
.route("/routes", get(routing::list_routes_handler))
.route("/routes", post(routing::create_route_handler))
.route("/routes/{id}", get(routing::get_route_handler))
.route("/routes/{id}", put(routing::update_route_handler))
.route("/routes/{id}", delete(routing::delete_route_handler))
// Firewall
.route(
"/firewall/rules",
get(firewall::list_firewall_rules_handler),
)
.route(
"/firewall/rules",
post(firewall::create_firewall_rule_handler),
)
.route(
"/firewall/rules/{id}",
get(firewall::get_firewall_rule_handler),
)
.route(
"/firewall/rules/{id}",
put(firewall::update_firewall_rule_handler),
)
.route(
"/firewall/rules/{id}",
delete(firewall::delete_firewall_rule_handler),
)
.route(
"/firewall/rules/{id}/enable",
post(firewall::enable_firewall_rule_handler),
)
.route(
"/firewall/rules/{id}/disable",
post(firewall::disable_firewall_rule_handler),
)
// Diagnostics
.route("/diagnostics/all", get(diagnostics::diagnose_all_handler))
.route(
"/diagnostics/{subsystem}",
get(diagnostics::diagnose_subsystem_handler),
)
// Client Profiles
.route(
"/client-profiles",
get(client_profiles::list_client_profiles_handler),
)
.route(
"/client-profiles/providers",
get(client_profiles::list_providers_handler),
)
.route(
"/client-profiles/devices",
get(client_profiles::list_devices_handler),
)
.route(
"/client-profiles/{id}",
get(client_profiles::get_client_profile_handler),
)
.route(
"/client-profiles/resolve",
post(client_profiles::resolve_client_profile_handler),
)
// Audit
.route("/audit", get(audit::list_audit_events_handler))
// Backups
.route("/backups", get(backups::list_backups_handler))
.route("/backups", post(backups::create_backup_record_handler))
.route("/backups/create", post(backups::trigger_backup_handler))
.route("/backups/{id}", get(backups::get_backup_handler))
.route(
"/backups/{id}/download",
get(backups::download_backup_handler),
)
.route(
"/backups/{id}/restore",
post(backups::restore_backup_handler),
)
.route("/backups/{id}", delete(backups::delete_backup_handler))
// Reconcile
.route(
"/reconcile/plan",
get(reconcile::get_reconciliation_plan_handler),
)
.route(
"/reconcile/apply",
post(reconcile::apply_reconciliation_handler),
)
// Attach authentication middleware
.route_layer(from_fn_with_state(state.auth.clone(), require_auth));
// 2. Public API routes (no authentication required)
let public_router = Router::new()
.route("/auth/login", post(auth::login_handler))
.route("/system/health", get(system::health_handler))
.route("/system/version", get(system::version_handler))
.route("/ws", get(ws::ws_handler));
// 3. Web UI routes and assets
let ui_router = Router::new()
.route("/", get(ui::index_handler))
.route("/ui", get(ui::index_handler))
.route("/assets/style.css", get(ui::stylesheet_handler));
// 4. Nest all under root and /api/v1 and attach global middleware
ui_router
.nest("/api/v1", public_router.merge(protected_router))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.with_state(state)
}
+166
View File
@@ -0,0 +1,166 @@
//! Network subnet HTTP handlers.
use crate::allocator::{IpAllocator, NetworkAllocation};
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::AppState;
use axum::Json;
use axum::extract::{Path, Query, State};
use chrono::Utc;
use nx9_wg_core::types::network::Network;
use nx9_wg_core::validation::validate_cidr;
use serde::Deserialize;
use std::net::IpAddr;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateNetworkRequest {
pub name: String,
pub cidr: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateNetworkRequest {
pub name: Option<String>,
pub cidr: Option<String>,
pub enabled: Option<bool>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AvailableIpsQuery {
pub limit: Option<usize>,
pub interface_id: Option<Uuid>,
}
/// GET /api/v1/networks
pub async fn list_networks_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<Network>>> {
let list = state.store.list_networks().await?;
Ok(Json(list))
}
/// POST /api/v1/networks
pub async fn create_network_handler(
State(state): State<AppState>,
Json(payload): Json<CreateNetworkRequest>,
) -> ApiResult<Json<Network>> {
if payload.name.trim().is_empty() {
return Err(ApiError::Validation(
"Network name cannot be empty".to_string(),
));
}
let cidr = validate_cidr(&payload.cidr)?;
let now = Utc::now().naive_utc();
let net = Network {
id: Uuid::new_v4(),
name: payload.name,
cidr,
enabled: true,
description: payload.description,
created_at: now,
updated_at: now,
};
state.store.create_network(&net).await?;
Ok(Json(net))
}
/// GET /api/v1/networks/{id}
pub async fn get_network_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Network>> {
let net = state
.store
.get_network(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
Ok(Json(net))
}
/// GET /api/v1/networks/{id}/available
pub async fn list_available_ips_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(query): Query<AvailableIpsQuery>,
) -> ApiResult<Json<Vec<IpAddr>>> {
let net = state
.store
.get_network(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
let interface = match query.interface_id {
Some(iface_id) => state.store.get_interface(iface_id).await?,
None => None,
};
let limit = query.limit.unwrap_or(10).min(100);
let available =
IpAllocator::list_available_ips(&state.store, &net, interface.as_ref(), limit).await?;
Ok(Json(available))
}
/// GET /api/v1/networks/{id}/allocations
pub async fn list_allocations_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<NetworkAllocation>>> {
let net = state
.store
.get_network(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
let allocations = IpAllocator::list_allocations(&state.store, &net).await?;
Ok(Json(allocations))
}
/// PUT /api/v1/networks/{id}
pub async fn update_network_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateNetworkRequest>,
) -> ApiResult<Json<Network>> {
let mut net = state
.store
.get_network(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
if let Some(ref name) = payload.name {
if name.trim().is_empty() {
return Err(ApiError::Validation(
"Network name cannot be empty".to_string(),
));
}
net.name = name.clone();
}
if let Some(ref cidr_str) = payload.cidr {
net.cidr = validate_cidr(cidr_str)?;
}
if let Some(enabled) = payload.enabled {
net.enabled = enabled;
}
if payload.description.is_some() {
net.description = payload.description;
}
state.store.update_network(&net).await?;
Ok(Json(net))
}
/// DELETE /api/v1/networks/{id}
pub async fn delete_network_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.delete_network(id).await?;
Ok(Json(GenericSuccess {
success: true,
message: format!("Network '{id}' deleted"),
}))
}
+581
View File
@@ -0,0 +1,581 @@
//! WireGuard Peer HTTP handlers.
use crate::allocator::IpAllocator;
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::{AppState, SystemEvent};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Response};
use chrono::{NaiveDateTime, Utc};
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
use nx9_wg_core::types::network::Network;
use nx9_wg_core::types::wireguard::{
Peer, PeerProfile, PeerState, PeerType, WireGuardPresharedKey, WireGuardPrivateKey,
WireGuardPublicKey,
};
use nx9_wg_core::validation::{validate_cidr, validate_mtu, validate_peer_name};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreatePeerRequest {
pub name: String,
pub peer_type: Option<PeerType>,
pub profile: Option<PeerProfile>,
pub network_id: Option<Uuid>,
pub public_key: Option<String>,
pub private_key: Option<String>,
pub preshared_key: Option<String>,
pub endpoint: Option<String>,
pub allowed_ips: Option<String>,
pub server_allowed_ips: Option<String>,
pub address_v4: Option<String>,
pub address_v6: Option<String>,
pub dns: Option<String>,
pub mtu: Option<u16>,
pub persistent_keepalive: Option<u16>,
pub expires_at: Option<NaiveDateTime>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePeerRequest {
pub name: Option<String>,
pub peer_type: Option<PeerType>,
pub profile: Option<PeerProfile>,
pub endpoint: Option<String>,
pub allowed_ips: Option<String>,
pub server_allowed_ips: Option<String>,
pub address_v4: Option<String>,
pub address_v6: Option<String>,
pub dns: Option<String>,
pub mtu: Option<u16>,
pub persistent_keepalive: Option<u16>,
pub expires_at: Option<NaiveDateTime>,
}
#[derive(Debug, Serialize)]
pub struct PeerLifecycleResponse {
pub id: Uuid,
pub name: String,
pub state: PeerState,
pub expires_at: Option<NaiveDateTime>,
pub is_expired: bool,
pub last_handshake_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// GET /api/v1/interfaces/{id}/peers
pub async fn list_peers_for_interface_handler(
State(state): State<AppState>,
Path(interface_id): Path<Uuid>,
) -> ApiResult<Json<Vec<Peer>>> {
let peers = state.store.list_peers_for_interface(interface_id).await?;
Ok(Json(peers))
}
/// POST /api/v1/interfaces/{id}/peers
pub async fn create_peer_handler(
State(state): State<AppState>,
Path(interface_id): Path<Uuid>,
Json(payload): Json<CreatePeerRequest>,
) -> ApiResult<Json<Peer>> {
// Verify interface exists
let interface = state
.store
.get_interface(interface_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Interface '{interface_id}' not found")))?;
validate_peer_name(&payload.name)?;
let mut address_v4 = match payload.address_v4.as_deref() {
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
_ => None,
};
let address_v6 = match payload.address_v6.as_deref() {
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
_ => None,
};
// If address_v4 was not explicitly provided, automatically allocate it
if address_v4.is_none() {
let net = match payload.network_id {
Some(net_id) => state
.store
.get_network(net_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Network '{net_id}' not found")))?,
None => Network {
id: Uuid::nil(),
name: format!("{}-subnet", interface.name),
cidr: interface.address_v4,
enabled: true,
description: None,
created_at: Utc::now().naive_utc(),
updated_at: Utc::now().naive_utc(),
},
};
let allocated =
IpAllocator::allocate_next_ip(&state.store, &net, Some(&interface), None).await?;
address_v4 = Some(allocated);
}
let allowed_ips = match payload.allowed_ips {
Some(s) if !s.trim().is_empty() => s,
_ => {
if let Some(v4) = address_v4 {
v4.to_string()
} else {
"0.0.0.0/0".to_string()
}
}
};
if let Some(m) = payload.mtu {
validate_mtu(m)?;
}
let (priv_k, pub_k) = match (payload.private_key, payload.public_key) {
(Some(priv_s), Some(pub_s)) => (
Some(WireGuardPrivateKey::new(priv_s)),
WireGuardPublicKey::new(pub_s),
),
(None, Some(pub_s)) => (None, WireGuardPublicKey::new(pub_s)),
_ => {
let (priv_k, pub_k) = generate_keypair();
(Some(priv_k), pub_k)
}
};
let preshared_key = match payload.preshared_key {
Some(psk) if !psk.trim().is_empty() => Some(WireGuardPresharedKey::new(psk)),
_ => Some(generate_preshared_key()),
};
let now = Utc::now().naive_utc();
let peer = Peer {
id: Uuid::new_v4(),
interface_id,
name: payload.name,
peer_type: payload.peer_type.unwrap_or(PeerType::RoadWarrior),
state: PeerState::Active,
public_key: pub_k,
private_key: priv_k,
preshared_key,
endpoint: payload.endpoint,
allowed_ips,
server_allowed_ips: payload.server_allowed_ips,
address_v4,
address_v6,
dns: payload.dns,
mtu: payload.mtu,
persistent_keepalive: payload.persistent_keepalive.or(Some(25)),
profile: payload.profile.unwrap_or(PeerProfile::FullTunnel),
expires_at: payload.expires_at,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state.store.create_peer(&peer).await?;
state.broadcast(SystemEvent::PeerChanged {
id: peer.id.to_string(),
action: "created".to_string(),
});
Ok(Json(peer))
}
/// GET /api/v1/peers/{id}
pub async fn get_peer_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Peer>> {
let peer = state
.store
.get_peer(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
Ok(Json(peer))
}
/// PUT /api/v1/peers/{id}
pub async fn update_peer_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdatePeerRequest>,
) -> ApiResult<Json<Peer>> {
let mut peer = state
.store
.get_peer(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
if let Some(ref name) = payload.name {
validate_peer_name(name)?;
peer.name = name.clone();
}
if let Some(pt) = payload.peer_type {
peer.peer_type = pt;
}
if let Some(prof) = payload.profile {
peer.profile = prof;
}
if payload.endpoint.is_some() {
peer.endpoint = payload.endpoint;
}
if let Some(ref ips) = payload.allowed_ips {
peer.allowed_ips = ips.clone();
}
if payload.server_allowed_ips.is_some() {
peer.server_allowed_ips = payload.server_allowed_ips;
}
if let Some(ref v4) = payload.address_v4 {
peer.address_v4 = Some(validate_cidr(v4)?);
}
if let Some(ref v6) = payload.address_v6 {
peer.address_v6 = Some(validate_cidr(v6)?);
}
if payload.dns.is_some() {
peer.dns = payload.dns;
}
if let Some(m) = payload.mtu {
validate_mtu(m)?;
peer.mtu = Some(m);
}
if payload.persistent_keepalive.is_some() {
peer.persistent_keepalive = payload.persistent_keepalive;
}
if payload.expires_at.is_some() {
peer.expires_at = payload.expires_at;
}
state.store.update_peer(&peer).await?;
state.broadcast(SystemEvent::PeerChanged {
id: id.to_string(),
action: "updated".to_string(),
});
Ok(Json(peer))
}
/// DELETE /api/v1/peers/{id}
pub async fn delete_peer_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.delete_peer(id).await?;
state.broadcast(SystemEvent::PeerChanged {
id: id.to_string(),
action: "deleted".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Peer '{id}' deleted"),
}))
}
/// POST /api/v1/peers/{id}/enable
pub async fn enable_peer_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.set_peer_state(id, PeerState::Active).await?;
state.broadcast(SystemEvent::PeerChanged {
id: id.to_string(),
action: "enabled".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Peer '{id}' enabled"),
}))
}
/// POST /api/v1/peers/{id}/disable
pub async fn disable_peer_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.set_peer_state(id, PeerState::Disabled).await?;
state.broadcast(SystemEvent::PeerChanged {
id: id.to_string(),
action: "disabled".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Peer '{id}' disabled"),
}))
}
/// POST /api/v1/peers/{id}/revoke
pub async fn revoke_peer_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.set_peer_state(id, PeerState::Revoked).await?;
state.broadcast(SystemEvent::PeerChanged {
id: id.to_string(),
action: "revoked".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Peer '{id}' revoked"),
}))
}
/// POST /api/v1/peers/{id}/expire
pub async fn expire_peer_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.mark_peer_expired(id).await?;
state.broadcast(SystemEvent::PeerChanged {
id: id.to_string(),
action: "expired".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Peer '{id}' marked as expired"),
}))
}
/// GET /api/v1/peers/{id}/lifecycle
pub async fn get_peer_lifecycle_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<PeerLifecycleResponse>> {
let peer = state
.store
.get_peer(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
let now = Utc::now().naive_utc();
let is_expired =
peer.state == PeerState::Expired || peer.expires_at.map(|exp| exp <= now).unwrap_or(false);
Ok(Json(PeerLifecycleResponse {
id: peer.id,
name: peer.name,
state: peer.state,
expires_at: peer.expires_at,
is_expired,
last_handshake_at: peer.last_handshake_at,
created_at: peer.created_at,
updated_at: peer.updated_at,
}))
}
#[derive(Debug, Default, Deserialize)]
pub struct ClientProfileQuery {
pub provider: Option<String>,
pub device: Option<String>,
pub connection: Option<String>,
pub nat: Option<String>,
pub mtu: Option<u16>,
pub profile: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct PeerQrResponse {
pub peer_id: Uuid,
pub svg: String,
pub data_url: String,
}
/// GET /api/v1/peers/{id}/config
pub async fn download_peer_config_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(query): Query<ClientProfileQuery>,
) -> ApiResult<Response> {
let peer = state
.store
.get_peer(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
let interface = state
.store
.get_interface(peer.interface_id)
.await?
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
let host = state
.store
.get_setting("server_endpoint")
.await?
.map(|s| s.value)
.unwrap_or_else(|| "127.0.0.1".to_string());
let resolved_profile = if query.provider.is_some()
|| query.device.is_some()
|| query.connection.is_some()
|| query.nat.is_some()
|| query.mtu.is_some()
|| query.profile.is_some()
{
let device = query
.device
.as_deref()
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let connection = query
.connection
.as_deref()
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let nat = query
.nat
.as_deref()
.map(nx9_wg_core::types::client_profile::NatType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
Some(
crate::profile_resolver::ClientProfileResolver::resolve(
&state.store,
query.provider.as_deref(),
device,
connection,
nat,
query.mtu,
query.profile.as_deref(),
interface.mtu,
)
.await?,
)
} else {
None
};
let config_str = nx9_wireguard::ClientConfigBuilder::build_with_profile(
&peer,
&interface,
&host,
resolved_profile.as_ref(),
)
.map_err(|e| ApiError::Internal(format!("Failed to build peer configuration: {e}")))?;
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8".parse().unwrap(),
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}.conf\"", peer.name)
.parse()
.unwrap(),
);
Ok((headers, axum::body::Body::from(config_str)).into_response())
}
/// GET /api/v1/peers/{id}/qr
pub async fn get_peer_qr_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(query): Query<ClientProfileQuery>,
) -> ApiResult<Json<PeerQrResponse>> {
let peer = state
.store
.get_peer(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
let interface = state
.store
.get_interface(peer.interface_id)
.await?
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
let host = state
.store
.get_setting("server_endpoint")
.await?
.map(|s| s.value)
.unwrap_or_else(|| "127.0.0.1".to_string());
let resolved_profile = if query.provider.is_some()
|| query.device.is_some()
|| query.connection.is_some()
|| query.nat.is_some()
|| query.mtu.is_some()
|| query.profile.is_some()
{
let device = query
.device
.as_deref()
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let connection = query
.connection
.as_deref()
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let nat = query
.nat
.as_deref()
.map(nx9_wg_core::types::client_profile::NatType::from_str)
.transpose()
.map_err(|e| ApiError::Validation(e.to_string()))?;
Some(
crate::profile_resolver::ClientProfileResolver::resolve(
&state.store,
query.provider.as_deref(),
device,
connection,
nat,
query.mtu,
query.profile.as_deref(),
interface.mtu,
)
.await?,
)
} else {
None
};
let config_str = nx9_wireguard::ClientConfigBuilder::build_with_profile(
&peer,
&interface,
&host,
resolved_profile.as_ref(),
)
.map_err(|e| ApiError::Internal(format!("Failed to build peer configuration: {e}")))?;
let svg = nx9_wireguard::generate_qr_svg(&config_str)
.map_err(|e| ApiError::Internal(format!("Failed to generate SVG QR code: {e}")))?;
let data_url = nx9_wireguard::generate_qr_data_url(&config_str)
.map_err(|e| ApiError::Internal(format!("Failed to generate PNG QR code: {e}")))?;
Ok(Json(PeerQrResponse {
peer_id: peer.id,
svg,
data_url,
}))
}
+34
View File
@@ -0,0 +1,34 @@
//! Reconciliation REST handlers.
use crate::error::ApiResult;
use crate::reconciliation::{ReconciliationEngine, ReconciliationPlan, ReconciliationReport};
use crate::state::AppState;
use axum::Json;
use axum::extract::State;
use nx9_wg_network::SimulatedNetworkEngine;
use nx9_wireguard::SimulatedWireGuardEngine;
use std::sync::Arc;
/// GET /api/v1/reconcile/plan
pub async fn get_reconciliation_plan_handler(
State(state): State<AppState>,
) -> ApiResult<Json<ReconciliationPlan>> {
let wg = Arc::new(SimulatedWireGuardEngine::new());
let net = Arc::new(SimulatedNetworkEngine::new());
let engine = ReconciliationEngine::new(state, wg, net);
let plan = engine.plan().await?;
Ok(Json(plan))
}
/// POST /api/v1/reconcile/apply
pub async fn apply_reconciliation_handler(
State(state): State<AppState>,
) -> ApiResult<Json<ReconciliationReport>> {
let wg = Arc::new(SimulatedWireGuardEngine::new());
let net = Arc::new(SimulatedNetworkEngine::new());
let engine = ReconciliationEngine::new(state, wg, net);
let report = engine.apply().await?;
Ok(Json(report))
}
+132
View File
@@ -0,0 +1,132 @@
//! Route HTTP handlers.
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::AppState;
use axum::Json;
use axum::extract::{Path, State};
use chrono::Utc;
use nx9_wg_core::types::network::Route;
use nx9_wg_core::validation::{validate_cidr, validate_ip};
use serde::Deserialize;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateRouteRequest {
pub network_id: Option<Uuid>,
pub interface_id: Option<Uuid>,
pub destination: String,
pub gateway: Option<String>,
pub metric: Option<u32>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateRouteRequest {
pub network_id: Option<Uuid>,
pub interface_id: Option<Uuid>,
pub destination: Option<String>,
pub gateway: Option<String>,
pub metric: Option<u32>,
pub enabled: Option<bool>,
pub description: Option<String>,
}
/// GET /api/v1/routes
pub async fn list_routes_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<Route>>> {
let list = state.store.list_routes().await?;
Ok(Json(list))
}
/// POST /api/v1/routes
pub async fn create_route_handler(
State(state): State<AppState>,
Json(payload): Json<CreateRouteRequest>,
) -> ApiResult<Json<Route>> {
let destination = validate_cidr(&payload.destination)?;
let gateway = match payload.gateway.as_deref() {
Some(s) if !s.trim().is_empty() => Some(validate_ip(s)?),
_ => None,
};
let now = Utc::now().naive_utc();
let route = Route {
id: Uuid::new_v4(),
network_id: payload.network_id,
interface_id: payload.interface_id,
destination,
gateway,
interface_name: None,
metric: payload.metric,
enabled: true,
description: payload.description,
created_at: now,
updated_at: now,
};
state.store.create_route(&route).await?;
Ok(Json(route))
}
/// GET /api/v1/routes/{id}
pub async fn get_route_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Route>> {
let route = state
.store
.get_route(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Route '{id}' not found")))?;
Ok(Json(route))
}
/// PUT /api/v1/routes/{id}
pub async fn update_route_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateRouteRequest>,
) -> ApiResult<Json<Route>> {
let mut route = state
.store
.get_route(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Route '{id}' not found")))?;
if payload.network_id.is_some() {
route.network_id = payload.network_id;
}
if payload.interface_id.is_some() {
route.interface_id = payload.interface_id;
}
if let Some(ref dest) = payload.destination {
route.destination = validate_cidr(dest)?;
}
if let Some(ref gw) = payload.gateway {
route.gateway = Some(validate_ip(gw)?);
}
if payload.metric.is_some() {
route.metric = payload.metric;
}
if let Some(enabled) = payload.enabled {
route.enabled = enabled;
}
if payload.description.is_some() {
route.description = payload.description;
}
state.store.update_route(&route).await?;
Ok(Json(route))
}
/// DELETE /api/v1/routes/{id}
pub async fn delete_route_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
state.store.delete_route(id).await?;
Ok(Json(GenericSuccess {
success: true,
message: format!("Route '{id}' deleted"),
}))
}
+117
View File
@@ -0,0 +1,117 @@
//! System overview, health, version, and settings HTTP handlers.
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::{AppState, SystemEvent};
use axum::Json;
use axum::extract::State;
use nx9_wg_core::types::settings::Setting;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct SystemOverview {
pub version: &'static str,
pub admin_initialized: bool,
pub interface_count: usize,
pub peer_count: usize,
pub network_count: usize,
}
#[derive(Debug, Serialize)]
pub struct HealthStatus {
pub status: &'static str,
pub database: &'static str,
}
#[derive(Debug, Serialize)]
pub struct VersionInfo {
pub name: &'static str,
pub version: &'static str,
}
#[derive(Debug, Deserialize)]
pub struct UpsertSettingRequest {
pub key: String,
pub value: String,
pub is_secret: Option<bool>,
}
/// GET /api/v1/system
pub async fn system_overview_handler(
State(state): State<AppState>,
) -> ApiResult<Json<SystemOverview>> {
let admin_initialized = state.store.admin_exists().await?;
let interfaces = state.store.list_interfaces().await?;
let peers = state.store.list_all_peers().await?;
let networks = state.store.list_networks().await?;
Ok(Json(SystemOverview {
version: env!("CARGO_PKG_VERSION"),
admin_initialized,
interface_count: interfaces.len(),
peer_count: peers.len(),
network_count: networks.len(),
}))
}
/// GET /api/v1/system/health
pub async fn health_handler(State(state): State<AppState>) -> ApiResult<Json<HealthStatus>> {
// Ping SQLite store
let _ = state
.store
.admin_exists()
.await
.map_err(|e| ApiError::Unavailable(format!("Database health check failed: {e}")))?;
Ok(Json(HealthStatus {
status: "healthy",
database: "connected",
}))
}
/// GET /api/v1/system/version
pub async fn version_handler() -> Json<VersionInfo> {
Json(VersionInfo {
name: "nx9-wg",
version: env!("CARGO_PKG_VERSION"),
})
}
/// GET /api/v1/system/settings
pub async fn list_settings_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<Setting>>> {
let mut settings = state.store.list_settings().await?;
// Redact secret values from generic listing
for s in &mut settings {
if s.is_secret {
s.value = "[REDACTED]".to_string();
}
}
Ok(Json(settings))
}
/// PUT /api/v1/system/settings
pub async fn upsert_setting_handler(
State(state): State<AppState>,
Json(payload): Json<UpsertSettingRequest>,
) -> ApiResult<Json<GenericSuccess>> {
if payload.key.trim().is_empty() {
return Err(ApiError::Validation(
"Setting key cannot be empty".to_string(),
));
}
let is_secret = payload.is_secret.unwrap_or(false);
state
.store
.set_setting(&payload.key, &payload.value, is_secret)
.await?;
state.broadcast(SystemEvent::SettingsChanged {
key: payload.key.clone(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Setting '{}' saved successfully", payload.key),
}))
}
+42
View File
@@ -0,0 +1,42 @@
//! Embedded Web UI Single Page Application and asset handlers.
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use nx9_wg_ui::generate_stylesheet;
/// Serves the single-page application root HTML document.
pub async fn index_handler() -> impl IntoResponse {
let html = render_spa_html();
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html; charset=utf-8")],
Html(html),
)
}
/// Serves the production CSS stylesheet.
pub async fn stylesheet_handler() -> Response {
let css = generate_stylesheet();
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("text/css; charset=utf-8"),
);
headers.insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=3600"),
);
(StatusCode::OK, headers, css).into_response()
}
/// Renders the complete HTML5 document shell for nx9-wg.
pub fn render_spa_html() -> String {
let raw_html = include_str!("app_index.html");
let css = generate_stylesheet();
let script = include_str!("app_client_js.js");
raw_html
.replace("/* STYLE_PLACEHOLDER */", &css)
.replace("/* SCRIPT_PLACEHOLDER */", script)
}
+71
View File
@@ -0,0 +1,71 @@
//! Real-time WebSocket event streaming.
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct WsAuthQuery {
pub token: Option<String>,
pub session: Option<String>,
}
/// GET /api/v1/ws
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
Query(query): Query<WsAuthQuery>,
) -> ApiResult<impl IntoResponse> {
// Authenticate WebSocket connection via query parameters
let authenticated = if let Some(ref raw_token) = query.token {
state.auth.authenticate_token(raw_token).await.is_ok()
} else if let Some(ref session_id) = query.session {
state.auth.authenticate_session(session_id).await.is_ok()
} else {
false
};
if !authenticated {
return Err(ApiError::Unauthenticated(
"WebSocket authentication required. Supply ?token=... or ?session=...".to_string(),
));
}
Ok(ws.on_upgrade(move |socket| handle_socket(socket, state)))
}
async fn handle_socket(socket: WebSocket, state: AppState) {
let (mut sender, mut receiver) = socket.split();
let mut rx = state.event_tx.subscribe();
// Spawn background task to stream broadcast events to client
let mut send_task = tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
if let Ok(json) = serde_json::to_string(&event) {
let msg = Message::Text(json.into());
if sender.send(msg).await.is_err() {
break;
}
}
}
});
// Client receive loop to handle close/ping/pong
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
if let Message::Close(_) = msg {
break;
}
}
});
// If either task exits, abort the other
tokio::select! {
_ = (&mut send_task) => recv_task.abort(),
_ = (&mut recv_task) => send_task.abort(),
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Application state and WebSocket event bus.
use crate::auth::service::AuthService;
use nx9_wg_core::types::audit::AuditEventType;
use nx9_wg_db::Store;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
/// Real-time system event broadcasted over WebSocket to connected clients.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload")]
pub enum SystemEvent {
AuditEvent {
event_type: AuditEventType,
message: Option<String>,
resource_type: Option<String>,
resource_id: Option<String>,
},
InterfaceChanged {
id: String,
action: String,
},
PeerChanged {
id: String,
action: String,
},
PeerHandshake {
id: String,
last_handshake_at: String,
},
SettingsChanged {
key: String,
},
}
/// Shared application state across HTTP handlers and WebSocket streams.
#[derive(Clone)]
pub struct AppState {
pub store: Store,
pub auth: AuthService,
pub event_tx: broadcast::Sender<SystemEvent>,
}
impl AppState {
/// Create a new AppState instance.
pub fn new(store: Store) -> Self {
let (event_tx, _) = broadcast::channel(256);
let auth = AuthService::new(store.clone());
Self {
store,
auth,
event_tx,
}
}
/// Broadcast an event to all connected WebSocket subscribers.
pub fn broadcast(&self, event: SystemEvent) {
let _ = self.event_tx.send(event);
}
}
@@ -0,0 +1,242 @@
//! Integration tests for Phase 2: Authentication, Admin Bootstrap, Rate Limiting, and Security.
use chrono::{Duration, Utc};
use nx9_wg_api::auth::{AuthService, BootstrapOptions, ResolvedSource, bootstrap_admin};
use nx9_wg_core::config::AppConfig;
use nx9_wg_db::Store;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_admin_bootstrap_all_sources_and_rejection() {
let config = AppConfig::default();
// 1. Bootstrap with explicit CLI password
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let opts = BootstrapOptions {
admin_username: Some("custom_admin".to_string()),
cli_password: Some("SecurePassword123!".to_string()),
..Default::default()
};
let res = bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap cli");
assert_eq!(res.source, ResolvedSource::CliArgument);
assert_eq!(res.admin.username, "custom_admin");
// Re-bootstrap must fail
let re_bootstrap = bootstrap_admin(&store, &config, &opts).await;
assert!(re_bootstrap.is_err(), "re-bootstrap must be rejected");
// 2. Bootstrap from password file
let store2 = Store::connect_in_memory().await.expect("connect");
store2.migrate().await.expect("migrate");
let tmp_file = NamedTempFile::new().expect("temp file");
std::fs::write(tmp_file.path(), "FileSecretPass999!\n").expect("write secret");
let opts2 = BootstrapOptions {
password_file: Some(tmp_file.path().to_str().unwrap().to_string()),
..Default::default()
};
let res2 = bootstrap_admin(&store2, &config, &opts2)
.await
.expect("bootstrap file");
assert_eq!(res2.source, ResolvedSource::PasswordFile);
assert_eq!(res2.admin.username, "admin");
// 3. Bootstrap from generated password
let store3 = Store::connect_in_memory().await.expect("connect");
store3.migrate().await.expect("migrate");
let gen_file = NamedTempFile::new().expect("gen file");
let opts3 = BootstrapOptions {
generate_password: true,
write_password_file: Some(gen_file.path().to_str().unwrap().to_string()),
..Default::default()
};
let res3 = bootstrap_admin(&store3, &config, &opts3)
.await
.expect("bootstrap gen");
assert_eq!(res3.source, ResolvedSource::Generated);
assert!(res3.generated_plaintext.is_some());
let gen_pw = res3.generated_plaintext.unwrap();
let written = std::fs::read_to_string(gen_file.path()).expect("read gen");
assert_eq!(written, gen_pw);
}
#[tokio::test]
async fn test_auth_service_login_and_rate_limiting() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let config = AppConfig::default();
let opts = BootstrapOptions {
cli_password: Some("AdminSecret123!".to_string()),
..Default::default()
};
bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap");
let auth = AuthService::new(store);
// Successful login
let session = auth
.login(
"admin",
"AdminSecret123!",
Some("192.168.1.50"),
Some("TestBrowser/1.0"),
)
.await
.expect("successful login");
assert_eq!(session.admin_id, 1);
assert_eq!(session.ip_address.as_deref(), Some("192.168.1.50"));
// Authenticate with valid session
let authenticated = auth
.authenticate_session(&session.id)
.await
.expect("authenticate session");
assert_eq!(authenticated.id, session.id);
// Wrong password login fails
let fail = auth
.login("admin", "WrongPass123!", Some("192.168.1.50"), None)
.await;
assert!(fail.is_err(), "wrong password must fail");
// Test rate-limit lockout after 5 failed attempts from same IP
let attacker_ip = "10.0.0.99";
for _ in 0..5 {
let _ = auth
.login("admin", "WrongPass123!", Some(attacker_ip), None)
.await;
}
// 6th attempt must be rejected with rate limit lockout even with correct password
let lockout = auth
.login("admin", "AdminSecret123!", Some(attacker_ip), None)
.await;
assert!(lockout.is_err());
let err_msg = lockout.unwrap_err().to_string();
assert!(
err_msg.contains("rate limited") || err_msg.contains("Too many failed"),
"error should indicate rate limit lockout: {err_msg}"
);
// Login from another IP should still succeed
let other_ip_login = auth
.login("admin", "AdminSecret123!", Some("192.168.1.60"), None)
.await;
assert!(
other_ip_login.is_ok(),
"different IP must not be locked out"
);
}
#[tokio::test]
async fn test_auth_service_password_change_invalidates_sessions() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let config = AppConfig::default();
let opts = BootstrapOptions {
cli_password: Some("OriginalPassword123!".to_string()),
..Default::default()
};
bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap");
let auth = AuthService::new(store.clone());
// Create two active sessions
let s1 = auth
.login("admin", "OriginalPassword123!", Some("1.1.1.1"), None)
.await
.expect("login 1");
let s2 = auth
.login("admin", "OriginalPassword123!", Some("2.2.2.2"), None)
.await
.expect("login 2");
assert!(auth.authenticate_session(&s1.id).await.is_ok());
assert!(auth.authenticate_session(&s2.id).await.is_ok());
// Change password
auth.change_password("NewRotatedPassword456!", Some("1.1.1.1"))
.await
.expect("change password");
// Both previous sessions must now be rejected
assert!(
auth.authenticate_session(&s1.id).await.is_err(),
"s1 must be invalidated"
);
assert!(
auth.authenticate_session(&s2.id).await.is_err(),
"s2 must be invalidated"
);
// Old password must fail; new password must succeed
assert!(
auth.login("admin", "OriginalPassword123!", None, None)
.await
.is_err()
);
let new_login = auth
.login("admin", "NewRotatedPassword456!", None, None)
.await
.expect("new login");
assert!(auth.authenticate_session(&new_login.id).await.is_ok());
}
#[tokio::test]
async fn test_auth_service_api_tokens() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let config = AppConfig::default();
let opts = BootstrapOptions {
cli_password: Some("AdminSecret123!".to_string()),
..Default::default()
};
bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap");
let auth = AuthService::new(store);
// Create API token
let (token_meta, raw_token) = auth
.create_api_token(
"Terraform Runner",
Some(Utc::now().naive_utc() + Duration::days(7)),
Some("10.0.0.1"),
)
.await
.expect("create token");
assert!(raw_token.starts_with("nx9_"));
assert_eq!(token_meta.name, "Terraform Runner");
// Authenticate with raw token
let authenticated = auth
.authenticate_token(&raw_token)
.await
.expect("authenticate token");
assert_eq!(authenticated.id, token_meta.id);
// Revoke token
auth.revoke_api_token(&token_meta.id, Some("10.0.0.1"))
.await
.expect("revoke");
// Authenticating revoked token must fail
assert!(
auth.authenticate_token(&raw_token).await.is_err(),
"revoked token must fail authentication"
);
}
@@ -0,0 +1,91 @@
//! Integration test suite for Backup and Restore engine.
use nx9_wg_api::backup::BackupService;
use nx9_wg_core::types::network::Network;
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use tempfile::tempdir;
use uuid::Uuid;
#[tokio::test]
async fn test_backup_create_verify_and_restore() {
let dir = tempdir().expect("create temp dir");
let active_db_path = dir.path().join("active.db");
let backup_dir = dir.path().join("backups");
let safety_dir = dir.path().join("safety");
let store = Store::connect(&active_db_path.to_string_lossy())
.await
.expect("connect to db");
store.migrate().await.expect("run migrations");
// Insert test record
let net = Network {
id: Uuid::new_v4(),
name: "test_lan".to_string(),
cidr: validate_cidr("10.50.0.0/24").unwrap(),
enabled: true,
description: Some("LAN subnet".to_string()),
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
};
store.create_network(&net).await.expect("create network");
// Create Backup
let (meta, backup_file) = BackupService::create_backup(
&store,
&backup_dir,
Some("Test backup snapshot"),
"test_admin",
Some("127.0.0.1"),
)
.await
.expect("create backup");
assert!(backup_file.exists());
assert!(meta.size_bytes > 0);
assert!(!meta.checksum.is_empty());
// Verify Backup
let is_valid =
BackupService::verify_backup(&backup_file, Some(&meta.checksum)).expect("verify backup");
assert!(is_valid, "Backup file should be valid SQLite archive");
// List backups from store
let backups = store.list_backups().await.expect("list backups");
assert_eq!(backups.len(), 1);
assert_eq!(backups[0].id, meta.id);
// Modify active DB by adding another network
let net2 = Network {
id: Uuid::new_v4(),
name: "temporary_lan".to_string(),
cidr: validate_cidr("10.99.0.0/24").unwrap(),
enabled: true,
description: None,
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
};
store.create_network(&net2).await.expect("create net2");
assert_eq!(store.list_networks().await.unwrap().len(), 2);
// Restore Backup
BackupService::restore_backup(
&store,
&backup_file,
&active_db_path,
&safety_dir,
"test_admin",
None,
)
.await
.expect("restore backup");
// Reopen store to verify restored content
let restored_store = Store::connect(&active_db_path.to_string_lossy())
.await
.expect("reconnect store");
let restored_networks = restored_store.list_networks().await.expect("list restored");
assert_eq!(restored_networks.len(), 1);
assert_eq!(restored_networks[0].name, "test_lan");
}
@@ -0,0 +1,184 @@
//! Integration tests for Client Profiles REST API endpoints and resolver.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use ipnet::IpNet;
use nx9_wg_api::state::AppState;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, ResolvedClientProfile};
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
use nx9_wg_db::Store;
use std::str::FromStr;
use tower::ServiceExt;
use uuid::Uuid;
async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
let store = Store::connect_in_memory().await.unwrap();
store.migrate().await.unwrap();
let now = chrono::Utc::now().naive_utc();
let hash = nx9_wg_core::crypto::hash_password("adminpassword123").unwrap();
store.create_admin("admin", &hash).await.unwrap();
// Create session token
let session = nx9_wg_core::types::auth::Session {
id: "test-session-id-12345".to_string(),
admin_id: 1,
created_at: now,
expires_at: now + chrono::Duration::hours(24),
last_seen_at: Some(now),
ip_address: Some("127.0.0.1".to_string()),
user_agent: Some("test-agent".to_string()),
};
store.create_session(&session).await.unwrap();
let (srv_priv, srv_pub) = generate_keypair();
let (peer_priv, peer_pub) = generate_keypair();
let interface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
private_key: srv_priv,
public_key: srv_pub,
listen_port: 51820,
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
dns: Some("1.1.1.1".to_string()),
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
store.create_interface(&interface).await.unwrap();
let peer = Peer {
id: Uuid::new_v4(),
interface_id: interface.id,
name: "test-mobile-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: peer_pub,
private_key: Some(peer_priv),
preshared_key: None,
endpoint: None,
allowed_ips: "10.0.0.2/32".to_string(),
server_allowed_ips: None,
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
store.create_peer(&peer).await.unwrap();
let state = AppState::new(store);
let app = nx9_wg_api::routes::build_api_router(state.clone());
(app, state, session.id, interface, peer)
}
#[tokio::test]
async fn test_client_profiles_endpoints() {
let (app, _state, session_id, _iface, peer) = setup_test_app().await;
// 1. List client profiles
let req = Request::builder()
.uri("/api/v1/client-profiles")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let profiles: Vec<ClientProfile> = serde_json::from_slice(&body).unwrap();
assert!(profiles.len() >= 10);
// 2. List distinct providers
let req = Request::builder()
.uri("/api/v1/client-profiles/providers")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let providers: Vec<String> = serde_json::from_slice(&body).unwrap();
assert!(providers.contains(&"tmobile".to_string()));
assert!(providers.contains(&"starlink".to_string()));
// 3. List device categories
let req = Request::builder()
.uri("/api/v1/client-profiles/devices")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// 4. Resolve client profile via POST
let resolve_body = serde_json::json!({
"connection": "mobile",
"device": "android",
"nat": "cgnat"
});
let req = Request::builder()
.method("POST")
.uri("/api/v1/client-profiles/resolve")
.header("Cookie", format!("nx9_session={session_id}"))
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&resolve_body).unwrap()))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let resolved: ResolvedClientProfile = serde_json::from_slice(&body).unwrap();
assert_eq!(resolved.mtu, 1280);
assert_eq!(resolved.connection_type, ConnectionType::Mobile);
// 5. Download peer .conf with mobile profile parameters
let req = Request::builder()
.uri(format!(
"/api/v1/peers/{}/config?connection=mobile&device=android",
peer.id
))
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let conf_str = String::from_utf8(body.to_vec()).unwrap();
assert!(conf_str.contains("MTU = 1280"));
assert!(conf_str.contains("PersistentKeepalive = 25"));
// 6. Get QR code with CGNAT profile parameters
let req = Request::builder()
.uri(format!("/api/v1/peers/{}/qr?nat=cgnat", peer.id))
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
}
@@ -0,0 +1,78 @@
//! Integration test suite for Reconciliation Engine.
use nx9_wg_api::reconciliation::ReconciliationEngine;
use nx9_wg_api::state::AppState;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::wireguard::Interface;
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use nx9_wg_network::SimulatedNetworkEngine;
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
use std::sync::Arc;
use tempfile::tempdir;
use uuid::Uuid;
#[tokio::test]
async fn test_reconciliation_engine_drift_detection_and_apply() {
let dir = tempdir().expect("create temp dir");
let db_path = dir.path().join("reconcile.db");
let store = Store::connect(&db_path.to_string_lossy())
.await
.expect("connect to db");
store.migrate().await.expect("run migrations");
let state = AppState::new(store.clone());
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let reconciler = ReconciliationEngine::new(state, wg_engine.clone(), net_engine.clone());
// 1. Create desired interface in SQLite
let (priv_key, pub_key) = generate_keypair();
let iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
private_key: priv_key,
public_key: pub_key,
listen_port: 51820,
address_v4: validate_cidr("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
};
store
.create_interface(&iface)
.await
.expect("create interface");
// 2. Compute plan: should detect missing wg0 in kernel
let plan = reconciler.plan().await.expect("compute plan");
assert!(plan.has_drift);
assert_eq!(plan.interface_changes, 1);
assert!(!plan.actions.is_empty());
// 3. Apply reconciliation
let report = reconciler.apply().await.expect("apply plan");
assert!(report.success);
assert!(report.executed_actions > 0);
// 4. Verify live WireGuard interface state
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap();
assert!(live_stats.is_some());
let stats = live_stats.unwrap();
assert_eq!(stats.name, "wg0");
assert_eq!(stats.listen_port, 51820);
// 5. Verify audit event was logged
let audits = store
.list_audit_events(&nx9_wg_db::AuditFilter::default(), 10, 0)
.await
.expect("list audits");
assert!(!audits.is_empty());
}
+236
View File
@@ -0,0 +1,236 @@
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::state::AppState;
use nx9_wg_core::config::AppConfig;
use nx9_wg_db::Store;
use serde_json::{Value, json};
use tower::ServiceExt;
async fn setup_test_app() -> (axum::Router, String) {
let store = Store::connect_in_memory().await.expect("connect in-memory");
store.migrate().await.expect("migrate");
let config = AppConfig::default();
let opts = BootstrapOptions {
cli_password: Some("AdminSecret123!".to_string()),
..Default::default()
};
bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap");
let state = AppState::new(store);
let app = build_api_router(state.clone());
// Login to get session ID
let login_req = Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"username": "admin",
"password": "AdminSecret123!"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(login_req).await.expect("login request");
assert_eq!(resp.status(), StatusCode::OK);
let cookie_header = resp
.headers()
.get(header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.unwrap();
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
(app, session_cookie)
}
#[tokio::test]
async fn test_public_health_and_version_endpoints() {
let (app, _) = setup_test_app().await;
// Health
let req = Request::builder()
.uri("/api/v1/system/health")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["status"], "healthy");
assert_eq!(val["database"], "connected");
// Version
let req = Request::builder()
.uri("/api/v1/system/version")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["name"], "nx9-wg");
}
#[tokio::test]
async fn test_protected_route_unauthenticated_rejection() {
let (app, _) = setup_test_app().await;
// Request protected route without auth
let req = Request::builder()
.uri("/api/v1/system")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_interfaces_and_peers_rest_lifecycle() {
let (app, cookie) = setup_test_app().await;
// 1. Create interface
let create_iface_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "wg0",
"listen_port": 51820,
"address_v4": "10.0.0.1/24",
"dns": "1.1.1.1"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let iface_val: Value = serde_json::from_slice(&body).unwrap();
let iface_id = iface_val["id"].as_str().unwrap();
assert_eq!(iface_val["name"], "wg0");
// 2. List interfaces
let list_req = Request::builder()
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 3. Create peer under interface
let create_peer_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "laptop-alice",
"peer_type": "road_warrior",
"profile": "full_tunnel",
"allowed_ips": "10.0.0.2/32"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let peer_val: Value = serde_json::from_slice(&body).unwrap();
let peer_id = peer_val["id"].as_str().unwrap();
assert_eq!(peer_val["name"], "laptop-alice");
// 4. Disable peer
let disable_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/peers/{peer_id}/disable"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(disable_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 5. Get peer and verify state
let get_peer_req = Request::builder()
.uri(format!("/api/v1/peers/{peer_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(get_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let peer_val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(peer_val["state"], "disabled");
// 6. Delete interface (cascades peer)
let del_iface_req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/interfaces/{iface_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(del_iface_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_networks_and_firewall_rest_lifecycle() {
let (app, cookie) = setup_test_app().await;
// Create network
let net_req = Request::builder()
.method("POST")
.uri("/api/v1/networks")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "Management Network",
"cidr": "10.10.0.0/16",
"description": "Internal management"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(net_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Create firewall rule
let fw_req = Request::builder()
.method("POST")
.uri("/api/v1/firewall/rules")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "Allow HTTPS",
"direction": "in",
"action": "accept",
"protocol": "tcp",
"destination_port": 443,
"priority": 10
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(fw_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let rule_val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(rule_val["name"], "Allow HTTPS");
assert_eq!(rule_val["priority"], 10);
}
@@ -0,0 +1,94 @@
//! Integration tests for embedded Web UI SPA and static asset endpoints.
use axum::body::to_bytes;
use axum::http::{Request, StatusCode};
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::state::AppState;
use nx9_wg_db::Store;
use tower::ServiceExt;
#[tokio::test]
async fn test_ui_spa_index_and_stylesheet_endpoints() {
let store = Store::connect_in_memory().await.expect("connect store");
store.migrate().await.expect("migrate store");
let state = AppState::new(store);
let app = build_api_router(state);
// 1. Test GET / (Root SPA Index)
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("execute request");
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap(),
"text/html; charset=utf-8"
);
let body_bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
let html = String::from_utf8_lossy(&body_bytes);
assert!(html.contains("nx9-wg — Native WireGuard Appliance"));
assert!(html.contains("NX9"));
assert!(html.contains("id=\"app-layout\""));
assert!(html.contains("id=\"sidebar\""));
assert!(html.contains("Dashboard"));
assert!(html.contains("Peers"));
assert!(html.contains("Diagnostics"));
assert!(html.contains("Administrator"));
// 2. Test GET /ui (Alias)
let res_ui = app
.clone()
.oneshot(
Request::builder()
.uri("/ui")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("execute request");
assert_eq!(res_ui.status(), StatusCode::OK);
// 3. Test GET /assets/style.css (Compiled CSS)
let res_css = app
.oneshot(
Request::builder()
.uri("/assets/style.css")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("execute request");
assert_eq!(res_css.status(), StatusCode::OK);
assert_eq!(
res_css
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap(),
"text/css; charset=utf-8"
);
let css_bytes = to_bytes(res_css.into_body(), 1024 * 1024).await.unwrap();
let css = String::from_utf8_lossy(&css_bytes);
assert!(css.contains("--bg-base: #0d1117;"));
assert!(css.contains("[data-theme=\"dark\"]"));
assert!(css.contains("[data-theme=\"light\"]"));
assert!(css.contains(".status-pass"));
assert!(css.contains(".status-fail"));
assert!(css.contains("@media (max-width: 768px)"));
}
@@ -0,0 +1,405 @@
use chrono::{Duration, Utc};
use nx9_wg_api::{AppState, DiagnosticsService, IpAllocator, ReconciliationEngine};
use nx9_wg_core::types::diagnostics::DiagnosticSubsystem;
use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::types::network::Network;
use nx9_wg_core::types::wireguard::{
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey, WireGuardPublicKey,
};
use nx9_wg_db::Store;
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
use std::net::IpAddr;
use std::sync::Arc;
use uuid::Uuid;
async fn setup_test_context() -> (
AppState,
Arc<SimulatedWireGuardEngine>,
Arc<SimulatedNetworkEngine>,
Arc<ReconciliationEngine>,
) {
let store = Store::connect_in_memory().await.expect("connect DB");
store.migrate().await.expect("migrate DB");
let state = AppState::new(store);
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let reconciler = Arc::new(ReconciliationEngine::new(
state.clone(),
wg_engine.clone(),
net_engine.clone(),
));
(state, wg_engine, net_engine, reconciler)
}
#[tokio::test]
async fn test_automatic_ip_allocation() {
let (state, _, _, _) = setup_test_context().await;
let now = Utc::now().naive_utc();
let net_id = Uuid::new_v4();
let network = Network {
id: net_id,
name: "Test-V4-Subnet".to_string(),
cidr: "10.50.0.0/24".parse().unwrap(),
enabled: true,
description: None,
created_at: now,
updated_at: now,
};
state
.store
.create_network(&network)
.await
.expect("create net");
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "wg50".to_string(),
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51850,
address_v4: "10.50.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
state
.store
.create_interface(&iface)
.await
.expect("create iface");
// First allocation: 10.50.0.1 is interface -> next available is 10.50.0.2/32
let ip1 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
.await
.expect("allocate ip1");
assert_eq!(ip1.to_string(), "10.50.0.2/32");
// Create a peer with this allocated IP
let peer1 = Peer {
id: Uuid::new_v4(),
interface_id: iface_id,
name: "peer-1".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new(
"peer1pubkey12345678901234567890123456789012=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: ip1.to_string(),
server_allowed_ips: None,
address_v4: Some(ip1),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state.store.create_peer(&peer1).await.expect("create peer1");
// Second allocation: next should be 10.50.0.3/32
let ip2 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
.await
.expect("allocate ip2");
assert_eq!(ip2.to_string(), "10.50.0.3/32");
// List available IPs: first should be 10.50.0.3
let available = IpAllocator::list_available_ips(&state.store, &network, Some(&iface), 5)
.await
.expect("list available");
assert_eq!(available.len(), 5);
assert_eq!(available[0], "10.50.0.3".parse::<IpAddr>().unwrap());
assert_eq!(available[1], "10.50.0.4".parse::<IpAddr>().unwrap());
// List allocations: should show peer1
let allocs = IpAllocator::list_allocations(&state.store, &network)
.await
.expect("list allocs");
assert_eq!(allocs.len(), 1);
assert_eq!(allocs[0].ip_address, "10.50.0.2/32");
assert_eq!(allocs[0].peer_name.as_deref(), Some("peer-1"));
}
#[tokio::test]
async fn test_peer_expiration_lifecycle() {
let (state, wg_engine, _net_engine, reconciler) = setup_test_context().await;
let now = Utc::now().naive_utc();
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "wg60".to_string(),
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51860,
address_v4: "10.60.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
state
.store
.create_interface(&iface)
.await
.expect("create iface");
// Peer with expiration in the past
let expired_peer_id = Uuid::new_v4();
let expired_peer = Peer {
id: expired_peer_id,
interface_id: iface_id,
name: "expired-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active, // marked active initially
public_key: WireGuardPublicKey::new(
"expiredpubkey123456789012345678901234567890=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.60.0.5/32".to_string(),
server_allowed_ips: None,
address_v4: Some("10.60.0.5/32".parse().unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: Some(now - Duration::hours(1)), // expired 1 hour ago
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state
.store
.create_peer(&expired_peer)
.await
.expect("create peer");
// Active peer without expiration
let active_peer_id = Uuid::new_v4();
let active_peer = Peer {
id: active_peer_id,
interface_id: iface_id,
name: "active-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new(
"activepubkey1234567890123456789012345678901=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.60.0.6/32".to_string(),
server_allowed_ips: None,
address_v4: Some("10.60.0.6/32".parse().unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: Some(now + Duration::days(30)),
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state
.store
.create_peer(&active_peer)
.await
.expect("create peer");
// Run reconciliation sweep
let swept = reconciler.sweep_expired_peers().await.expect("sweep");
assert_eq!(swept, 1);
// Verify expired_peer transitioned to Expired
let p1 = state
.store
.get_peer(expired_peer_id)
.await
.expect("get")
.unwrap();
assert_eq!(p1.state, PeerState::Expired);
// Verify active_peer remains Active
let p2 = state
.store
.get_peer(active_peer_id)
.await
.expect("get")
.unwrap();
assert_eq!(p2.state, PeerState::Active);
// Reconcile apply ensures only active peers are synced to WireGuard kernel engine
let rep = reconciler.apply().await.expect("apply");
assert!(rep.success);
let stats = wg_engine
.get_interface_stats("wg60")
.await
.unwrap()
.unwrap();
// Only active peer should be live in interface
assert_eq!(stats.peers.len(), 1);
assert_eq!(stats.peers[0].public_key, active_peer.public_key.as_str());
}
#[tokio::test]
async fn test_peer_firewall_and_port_ranges() {
let (state, _, net_engine, reconciler) = setup_test_context().await;
let now = Utc::now().naive_utc();
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "wg70".to_string(),
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51870,
address_v4: "10.70.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
state
.store
.create_interface(&iface)
.await
.expect("create iface");
let peer_id = Uuid::new_v4();
let peer = Peer {
id: peer_id,
interface_id: iface_id,
name: "dev-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new(
"devpeerpubkey1234567890123456789012345678901=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.70.0.10/32".to_string(),
server_allowed_ips: None,
address_v4: Some("10.70.0.10/32".parse().unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state.store.create_peer(&peer).await.expect("create peer");
// Peer-specific rule with multi-port and TCP/UDP protocol
let rule = FirewallRule {
id: Uuid::new_v4(),
name: "Allow Dev Ports".to_string(),
interface_id: Some(iface_id),
peer_id: Some(peer_id),
direction: FirewallDirection::Forward,
action: FirewallAction::Accept,
protocol: FirewallProtocol::TcpUdp,
source: None,
destination: None,
source_port: None,
destination_port: None,
port_range: Some("8000-8100".to_string()),
priority: 10,
enabled: true,
description: Some("Peer port range".to_string()),
created_at: now,
updated_at: now,
};
state
.store
.create_firewall_rule(&rule)
.await
.expect("create rule");
// Apply reconciliation to compile ruleset
reconciler.apply().await.expect("apply");
let ruleset = net_engine
.get_active_nftables_ruleset()
.await
.expect("get ruleset");
assert!(ruleset.contains("table inet nx9_wg"));
// Resolved peer IP 10.70.0.10, protocol meta l4proto { tcp, udp }, and port range 8000-8100
assert!(ruleset.contains("ip saddr 10.70.0.10"));
assert!(ruleset.contains("meta l4proto { tcp, udp }"));
assert!(ruleset.contains("th dport 8000-8100 accept"));
}
#[tokio::test]
async fn test_native_diagnostics_subsystem() {
let (state, wg_engine, net_engine, reconciler) = setup_test_context().await;
let diag = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
let all_reports = diag.diagnose_all().await.expect("diagnose all");
assert!(!all_reports.is_empty());
let sys_report = diag
.run_diagnostic(DiagnosticSubsystem::System, None)
.await
.expect("diag system");
assert_eq!(sys_report.len(), 1);
assert_eq!(sys_report[0].subsystem, "system");
let fwd_report = diag
.run_diagnostic(DiagnosticSubsystem::Forwarding, None)
.await
.expect("diag fwd");
assert_eq!(fwd_report.len(), 1);
assert_eq!(fwd_report[0].subsystem, "forwarding");
}