cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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"),
|
||||
}))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"),
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"),
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"),
|
||||
}))
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user