release: NX9-WG v1.0.0
This commit is contained in:
1 parent
c8a9b7cde6
commit
4dfe42fe68
42 files changed
+4689
-336
No files matched your search
@@ -3,6 +3,7 @@
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::{AppState, SystemEvent};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::audit::AuditEventType;
|
||||
use nx9_wg_core::types::wireguard::PeerState;
|
||||
use nx9_wg_network::NetworkEngine;
|
||||
@@ -11,6 +12,36 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Check if a slice of live address strings contains the desired IpNet.
|
||||
fn matches_ipnet(live_addrs: &[String], desired: &IpNet) -> bool {
|
||||
live_addrs.iter().any(|s| {
|
||||
if let Ok(net) = s.parse::<IpNet>() {
|
||||
net.addr() == desired.addr() && net.prefix_len() == desired.prefix_len()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if live WireGuard peer allowed IPs match desired server-side allowed IPs.
|
||||
fn matches_allowed_ips(live_allowed_ips: &[String], desired_str: &str) -> bool {
|
||||
let desired_nets: std::collections::BTreeSet<IpNet> = desired_str
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||
.collect();
|
||||
|
||||
let live_nets: std::collections::BTreeSet<IpNet> = live_allowed_ips
|
||||
.iter()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||
.collect();
|
||||
|
||||
desired_nets == live_nets
|
||||
}
|
||||
|
||||
/// Individual action proposed or taken by the reconciler.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReconciliationAction {
|
||||
@@ -53,6 +84,8 @@ pub struct ReconciliationReport {
|
||||
#[serde(default)]
|
||||
pub status: ReconciliationStatus,
|
||||
pub executed_actions: usize,
|
||||
#[serde(default)]
|
||||
pub failed_actions: usize,
|
||||
pub details: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -132,40 +165,66 @@ impl ReconciliationEngine {
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
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();
|
||||
let iface_exists = live_interfaces.contains(&iface.name) || live_stats.is_some();
|
||||
|
||||
match live_stats.as_ref() {
|
||||
Some(stats) => {
|
||||
if stats.public_key != iface.public_key.as_str()
|
||||
|| stats.listen_port != iface.listen_port
|
||||
if iface_exists {
|
||||
if let Some(stats) = live_stats.as_ref() {
|
||||
let mut drift_reasons = Vec::new();
|
||||
|
||||
if !stats.public_key.is_empty()
|
||||
&& stats.public_key != iface.public_key.as_str()
|
||||
{
|
||||
drift_reasons.push("public key mismatch".to_string());
|
||||
}
|
||||
if stats.listen_port != 0 && stats.listen_port != iface.listen_port {
|
||||
drift_reasons.push("listen port mismatch".to_string());
|
||||
}
|
||||
if !matches_ipnet(&stats.addresses, &iface.address_v4) {
|
||||
drift_reasons
|
||||
.push(format!("missing IPv4 address '{}'", iface.address_v4));
|
||||
}
|
||||
if let Some(ref v6) = iface.address_v6
|
||||
&& !matches_ipnet(&stats.addresses, v6)
|
||||
{
|
||||
drift_reasons.push(format!("missing IPv6 address '{v6}'"));
|
||||
}
|
||||
if let Some(desired_mtu) = iface.mtu
|
||||
&& let Some(live_mtu) = stats.mtu
|
||||
&& live_mtu != desired_mtu as u32
|
||||
{
|
||||
drift_reasons.push(format!(
|
||||
"MTU mismatch (live: {live_mtu}, desired: {desired_mtu})"
|
||||
));
|
||||
}
|
||||
if !stats.is_up {
|
||||
drift_reasons.push("interface link is down".to_string());
|
||||
}
|
||||
|
||||
if !drift_reasons.is_empty() {
|
||||
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
|
||||
"Interface '{}' configuration drift detected ({}); synchronize link, address, port, or keys",
|
||||
iface.name,
|
||||
drift_reasons.join(", ")
|
||||
),
|
||||
});
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
@@ -175,16 +234,86 @@ impl ReconciliationEngine {
|
||||
.filter(|p| p.state == PeerState::Active)
|
||||
.collect();
|
||||
|
||||
let live_peers_map: std::collections::HashMap<
|
||||
String,
|
||||
&nx9_wireguard::LivePeerStats,
|
||||
> = if let Some(ref stats) = live_stats {
|
||||
stats
|
||||
.peers
|
||||
.iter()
|
||||
.map(|p| (p.public_key.clone(), p))
|
||||
.collect()
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
for p in &active_desired_peers {
|
||||
if !live_peer_keys.contains(&p.public_key.as_str().to_string()) {
|
||||
let pub_key_str = p.public_key.as_str();
|
||||
let desired_server_allowed = p.server_wireguard_allowed_ips();
|
||||
|
||||
if let Some(live_p) = live_peers_map.get(pub_key_str) {
|
||||
// Peer is present in live kernel interface. Verify semantic drift:
|
||||
let mut peer_drifts = Vec::new();
|
||||
|
||||
if !matches_allowed_ips(&live_p.allowed_ips, &desired_server_allowed) {
|
||||
peer_drifts.push(format!(
|
||||
"AllowedIPs drift (live: [{:?}], desired: [{desired_server_allowed}])",
|
||||
live_p.allowed_ips
|
||||
));
|
||||
}
|
||||
|
||||
if let (Some(desired_ka), Some(live_ka)) =
|
||||
(p.persistent_keepalive, live_p.persistent_keepalive)
|
||||
&& live_ka != desired_ka
|
||||
{
|
||||
peer_drifts.push(format!(
|
||||
"persistent keepalive drift (live: {live_ka}s, desired: {desired_ka}s)"
|
||||
));
|
||||
}
|
||||
|
||||
if !peer_drifts.is_empty() {
|
||||
plan.actions.push(ReconciliationAction {
|
||||
subsystem: "wireguard".to_string(),
|
||||
resource_id: p.id.to_string(),
|
||||
action_type: "update_peer".to_string(),
|
||||
description: format!(
|
||||
"Peer '{}' ({}) drift detected: {}; re-sync in kernel",
|
||||
p.name,
|
||||
pub_key_str,
|
||||
peer_drifts.join(", ")
|
||||
),
|
||||
});
|
||||
plan.peer_changes += 1;
|
||||
}
|
||||
|
||||
// Update operational telemetry (handshake timestamp and learned endpoint) from kernel
|
||||
if live_p.last_handshake_at.is_some() || live_p.endpoint.is_some() {
|
||||
let hs_newer = live_p.last_handshake_at.is_some()
|
||||
&& live_p.last_handshake_at != p.last_handshake_at;
|
||||
let ep_newer = live_p.endpoint.is_some()
|
||||
&& live_p.endpoint.as_deref() != p.endpoint.as_deref();
|
||||
|
||||
if hs_newer || ep_newer {
|
||||
let _ = self
|
||||
.state
|
||||
.store
|
||||
.update_peer_learned_telemetry(
|
||||
p.id,
|
||||
live_p.last_handshake_at.or(p.last_handshake_at),
|
||||
live_p.endpoint.as_deref().or(p.endpoint.as_deref()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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",
|
||||
"Peer '{}' ({}) missing in live interface; add to kernel with AllowedIPs [{desired_server_allowed}]",
|
||||
p.name,
|
||||
p.public_key.as_str()
|
||||
pub_key_str
|
||||
),
|
||||
});
|
||||
plan.peer_changes += 1;
|
||||
@@ -293,7 +422,7 @@ impl ReconciliationEngine {
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if expected_ruleset.trim() != active_ruleset.trim() {
|
||||
if nx9_wg_network::has_nftables_drift(&expected_ruleset, &active_ruleset) {
|
||||
plan.actions.push(ReconciliationAction {
|
||||
subsystem: "firewall".to_string(),
|
||||
resource_id: "nftables".to_string(),
|
||||
@@ -340,6 +469,17 @@ impl ReconciliationEngine {
|
||||
// Sweep expired peers
|
||||
let _ = self.sweep_expired_peers().await;
|
||||
|
||||
let initial_plan = self.plan().await.unwrap_or_default();
|
||||
if !initial_plan.has_drift {
|
||||
return Ok(ReconciliationReport {
|
||||
success: true,
|
||||
status: ReconciliationStatus::Converged,
|
||||
executed_actions: 0,
|
||||
failed_actions: 0,
|
||||
details: vec!["System is already fully converged; zero drift detected".to_string()],
|
||||
});
|
||||
}
|
||||
|
||||
let desired_interfaces = self.state.store.list_interfaces().await?;
|
||||
let mut details = Vec::new();
|
||||
|
||||
@@ -419,10 +559,28 @@ impl ReconciliationEngine {
|
||||
|
||||
// 4. Verify post-apply convergence
|
||||
let post_plan = self.plan().await.unwrap_or_default();
|
||||
let (success, status) = if !post_plan.has_drift {
|
||||
(true, ReconciliationStatus::Converged)
|
||||
let (success, status, executed_actions, failed_actions) = if !post_plan.has_drift {
|
||||
(
|
||||
true,
|
||||
ReconciliationStatus::Converged,
|
||||
initial_plan.actions.len(),
|
||||
0,
|
||||
)
|
||||
} else {
|
||||
(false, ReconciliationStatus::DriftRemains)
|
||||
let remaining = post_plan.actions.len();
|
||||
let completed = initial_plan.actions.len().saturating_sub(remaining);
|
||||
for action in &post_plan.actions {
|
||||
details.push(format!(
|
||||
"Unresolved drift: [{}] {}",
|
||||
action.subsystem, action.description
|
||||
));
|
||||
}
|
||||
(
|
||||
false,
|
||||
ReconciliationStatus::DriftRemains,
|
||||
completed,
|
||||
remaining,
|
||||
)
|
||||
};
|
||||
|
||||
// 5. Audit reconciliation run
|
||||
@@ -435,8 +593,8 @@ impl ReconciliationEngine {
|
||||
Some("reconciliation"),
|
||||
None,
|
||||
Some(&format!(
|
||||
"Reconciliation applied {} actions (status: {status:?})",
|
||||
details.len()
|
||||
"Reconciliation applied {} actions (status: {status:?}, failed: {failed_actions})",
|
||||
executed_actions
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
@@ -446,8 +604,8 @@ impl ReconciliationEngine {
|
||||
self.state.broadcast(SystemEvent::AuditEvent {
|
||||
event_type: AuditEventType::ReconciliationRun,
|
||||
message: Some(format!(
|
||||
"Reconciliation applied {} actions (status: {status:?})",
|
||||
details.len()
|
||||
"Reconciliation applied {} actions (status: {status:?}, failed: {failed_actions})",
|
||||
executed_actions
|
||||
)),
|
||||
resource_type: Some("reconciliation".to_string()),
|
||||
resource_id: None,
|
||||
@@ -456,7 +614,8 @@ impl ReconciliationEngine {
|
||||
Ok(ReconciliationReport {
|
||||
success,
|
||||
status,
|
||||
executed_actions: details.len(),
|
||||
executed_actions,
|
||||
failed_actions,
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,31 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-layout">
|
||||
<!-- Unauthenticated Login View -->
|
||||
<div id="login-view" style="display: none;">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<span class="brand-mark">NX9</span>
|
||||
<h2>Administrator Login</h2>
|
||||
<p>Sign in to nx9-wg Native Linux Appliance</p>
|
||||
</div>
|
||||
<div id="login-error-msg" class="login-error" style="display: none;"></div>
|
||||
<form id="login-form" onsubmit="event.preventDefault(); submitLogin();">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-username">Username</label>
|
||||
<input type="text" id="login-username" class="form-input" value="admin" required autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-password">Password</label>
|
||||
<input type="password" id="login-password" class="form-input" required autofocus autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" id="login-submit-btn" class="btn btn-primary" style="width: 100%; margin-top: 8px;">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated Application Shell -->
|
||||
<div id="app-layout" style="display: none;">
|
||||
<!-- Top Application Bar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use crate::auth::middleware::AuthenticatedAdmin;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::extract::{Path, Request, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::http::header::{AUTHORIZATION, COOKIE, SET_COOKIE};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use chrono::NaiveDateTime;
|
||||
@@ -67,9 +67,8 @@ pub async fn login_handler(
|
||||
.await?;
|
||||
|
||||
let cookie_val = format!(
|
||||
"nx9_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
|
||||
session.id,
|
||||
24 * 3600
|
||||
"nx9_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400",
|
||||
session.id
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -89,12 +88,37 @@ pub async fn login_handler(
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
pub async fn logout_handler(State(state): State<AppState>, req: Request) -> ApiResult<Response> {
|
||||
let mut session_to_delete = None;
|
||||
|
||||
// 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_") {
|
||||
session_to_delete = Some(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try session cookie (nx9_session=...)
|
||||
if session_to_delete.is_none()
|
||||
&& 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=") {
|
||||
session_to_delete = Some(session_id.trim().to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref session_id) = session_to_delete {
|
||||
let _ = state.auth.logout(session_id, None).await;
|
||||
}
|
||||
|
||||
let cookie_val = "nx9_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
|
||||
|
||||
@@ -38,6 +38,7 @@ pub struct UpdateInterfaceRequest {
|
||||
pub address_v6: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub dns: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub pre_up: Option<String>,
|
||||
pub post_up: Option<String>,
|
||||
pub pre_down: Option<String>,
|
||||
@@ -144,8 +145,18 @@ pub async fn update_interface_handler(
|
||||
.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();
|
||||
let trimmed = name.trim();
|
||||
validate_interface_name(trimmed)?;
|
||||
if iface.name != trimmed {
|
||||
if let Ok(Some(existing)) = state.store.get_interface_by_name(trimmed).await
|
||||
&& existing.id != iface.id
|
||||
{
|
||||
return Err(ApiError::Validation(format!(
|
||||
"Interface with name '{trimmed}' already exists"
|
||||
)));
|
||||
}
|
||||
iface.name = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(port) = payload.listen_port {
|
||||
validate_listen_port(port)?;
|
||||
@@ -155,14 +166,25 @@ pub async fn update_interface_handler(
|
||||
iface.address_v4 = validate_cidr(v4)?;
|
||||
}
|
||||
if let Some(ref v6) = payload.address_v6 {
|
||||
iface.address_v6 = Some(validate_cidr(v6)?);
|
||||
if v6.trim().is_empty() {
|
||||
iface.address_v6 = None;
|
||||
} else {
|
||||
iface.address_v6 = Some(validate_cidr(v6.trim())?);
|
||||
}
|
||||
}
|
||||
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 dns.trim().is_empty() {
|
||||
iface.dns = None;
|
||||
} else {
|
||||
iface.dns = Some(dns.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(en) = payload.enabled {
|
||||
iface.enabled = en;
|
||||
}
|
||||
if payload.pre_up.is_some() {
|
||||
iface.pre_up = payload.pre_up;
|
||||
@@ -177,6 +199,8 @@ pub async fn update_interface_handler(
|
||||
iface.post_down = payload.post_down;
|
||||
}
|
||||
|
||||
iface.updated_at = Utc::now().naive_utc();
|
||||
|
||||
state.store.update_interface(&iface).await?;
|
||||
|
||||
state.broadcast(SystemEvent::InterfaceChanged {
|
||||
|
||||
@@ -28,7 +28,6 @@ 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))
|
||||
@@ -36,6 +35,7 @@ pub fn build_api_router(state: AppState) -> Router {
|
||||
.route("/auth/tokens/{id}", delete(auth::revoke_token_handler))
|
||||
// System
|
||||
.route("/system", get(system::system_overview_handler))
|
||||
.route("/system/live-state", get(system::live_state_handler))
|
||||
.route("/system/settings", get(system::list_settings_handler))
|
||||
.route("/system/settings", put(system::upsert_setting_handler))
|
||||
// Interfaces
|
||||
@@ -68,6 +68,7 @@ pub fn build_api_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/interfaces/{id}/peers", post(peers::create_peer_handler))
|
||||
// Peers
|
||||
.route("/peers", get(peers::list_peers_handler))
|
||||
.route("/peers/{id}", get(peers::get_peer_handler))
|
||||
.route("/peers/{id}", put(peers::update_peer_handler))
|
||||
.route("/peers/{id}", delete(peers::delete_peer_handler))
|
||||
@@ -191,6 +192,7 @@ pub fn build_api_router(state: AppState) -> Router {
|
||||
// 2. Public API routes (no authentication required)
|
||||
let public_router = Router::new()
|
||||
.route("/auth/login", post(auth::login_handler))
|
||||
.route("/auth/logout", post(auth::logout_handler))
|
||||
.route("/system/health", get(system::health_handler))
|
||||
.route("/system/version", get(system::version_handler))
|
||||
.route("/ws", get(ws::ws_handler));
|
||||
|
||||
@@ -8,6 +8,7 @@ use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
@@ -67,13 +68,200 @@ pub struct PeerLifecycleResponse {
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PeerResponse {
|
||||
pub id: Uuid,
|
||||
pub interface_id: Uuid,
|
||||
pub name: String,
|
||||
pub peer_type: PeerType,
|
||||
pub state: PeerState,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub private_key: Option<WireGuardPrivateKey>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preshared_key: Option<WireGuardPresharedKey>,
|
||||
pub endpoint: Option<String>,
|
||||
pub allowed_ips: String,
|
||||
pub server_allowed_ips: Option<String>,
|
||||
pub address_v4: Option<IpNet>,
|
||||
pub address_v6: Option<IpNet>,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
pub profile: PeerProfile,
|
||||
pub expires_at: Option<String>,
|
||||
pub last_handshake_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rx_bytes: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tx_bytes: Option<u64>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn format_utc_rfc3339(dt: NaiveDateTime) -> String {
|
||||
let utc_dt = chrono::DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc);
|
||||
utc_dt.to_rfc3339()
|
||||
}
|
||||
|
||||
fn to_peer_response(peer: Peer, live_stats: Option<&nx9_wireguard::LivePeerStats>) -> PeerResponse {
|
||||
let (endpoint, last_handshake_at, rx_bytes, tx_bytes) = if let Some(live) = live_stats {
|
||||
let ep = live.endpoint.clone().or(peer.endpoint.clone());
|
||||
let hs = live.last_handshake_at.or(peer.last_handshake_at);
|
||||
(ep, hs, Some(live.rx_bytes), Some(live.tx_bytes))
|
||||
} else {
|
||||
(peer.endpoint.clone(), peer.last_handshake_at, None, None)
|
||||
};
|
||||
|
||||
PeerResponse {
|
||||
id: peer.id,
|
||||
interface_id: peer.interface_id,
|
||||
name: peer.name,
|
||||
peer_type: peer.peer_type,
|
||||
state: peer.state,
|
||||
public_key: peer.public_key,
|
||||
private_key: peer.private_key,
|
||||
preshared_key: peer.preshared_key,
|
||||
endpoint,
|
||||
allowed_ips: peer.allowed_ips,
|
||||
server_allowed_ips: peer.server_allowed_ips,
|
||||
address_v4: peer.address_v4,
|
||||
address_v6: peer.address_v6,
|
||||
dns: peer.dns,
|
||||
mtu: peer.mtu,
|
||||
persistent_keepalive: peer.persistent_keepalive,
|
||||
profile: peer.profile,
|
||||
expires_at: peer.expires_at.map(format_utc_rfc3339),
|
||||
last_handshake_at: last_handshake_at.map(format_utc_rfc3339),
|
||||
rx_bytes,
|
||||
tx_bytes,
|
||||
created_at: format_utc_rfc3339(peer.created_at),
|
||||
updated_at: format_utc_rfc3339(peer.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich_peers_with_live_telemetry(state: &AppState, peers: Vec<Peer>) -> Vec<PeerResponse> {
|
||||
if peers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// 1. Gather all unique interface IDs from peers and find interface names
|
||||
let mut iface_map = std::collections::HashMap::new();
|
||||
for p in &peers {
|
||||
if !iface_map.contains_key(&p.interface_id)
|
||||
&& let Ok(Some(iface)) = state.store.get_interface(p.interface_id).await
|
||||
{
|
||||
iface_map.insert(p.interface_id, iface.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Query live interface stats for each interface
|
||||
let mut live_map = std::collections::HashMap::new();
|
||||
for iface_name in iface_map.values() {
|
||||
if let Ok(Some(stats)) = state.wg_engine.get_interface_stats(iface_name).await {
|
||||
for lp in stats.peers {
|
||||
live_map.insert(lp.public_key.clone(), lp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Construct enriched PeerResponse and update DB cache if newer
|
||||
let mut responses = Vec::with_capacity(peers.len());
|
||||
for p in peers {
|
||||
let pub_key_str = p.public_key.to_string();
|
||||
let live_stat = live_map.get(&pub_key_str);
|
||||
|
||||
if let Some(live) = live_stat {
|
||||
let hs_newer =
|
||||
live.last_handshake_at.is_some() && live.last_handshake_at != p.last_handshake_at;
|
||||
let ep_newer =
|
||||
live.endpoint.is_some() && live.endpoint.as_deref() != p.endpoint.as_deref();
|
||||
|
||||
if hs_newer || ep_newer {
|
||||
let latest_hs = live.last_handshake_at.or(p.last_handshake_at);
|
||||
let latest_ep = live.endpoint.as_deref().or(p.endpoint.as_deref());
|
||||
let _ = state
|
||||
.store
|
||||
.update_peer_learned_telemetry(p.id, latest_hs, latest_ep)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
responses.push(to_peer_response(p, live_stat));
|
||||
}
|
||||
|
||||
responses
|
||||
}
|
||||
|
||||
/// GET /api/v1/peers
|
||||
pub async fn list_peers_handler(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<PeerResponse>>> {
|
||||
let peers = state.store.list_all_peers().await?;
|
||||
let enriched = enrich_peers_with_live_telemetry(&state, peers).await;
|
||||
Ok(Json(enriched))
|
||||
}
|
||||
|
||||
/// 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>>> {
|
||||
) -> ApiResult<Json<Vec<PeerResponse>>> {
|
||||
let peers = state.store.list_peers_for_interface(interface_id).await?;
|
||||
Ok(Json(peers))
|
||||
let enriched = enrich_peers_with_live_telemetry(&state, peers).await;
|
||||
Ok(Json(enriched))
|
||||
}
|
||||
|
||||
async fn validate_no_server_allowed_ips_conflict(
|
||||
store: &nx9_wg_db::Store,
|
||||
interface_id: Uuid,
|
||||
peer_id: Option<Uuid>,
|
||||
candidate_server_allowed_ips: &str,
|
||||
) -> ApiResult<()> {
|
||||
if candidate_server_allowed_ips.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let candidate_nets: Vec<IpNet> = candidate_server_allowed_ips
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||
.collect();
|
||||
|
||||
if candidate_nets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let existing_peers = store.list_peers_for_interface(interface_id).await?;
|
||||
for ep in existing_peers {
|
||||
if ep.state != PeerState::Active {
|
||||
continue;
|
||||
}
|
||||
if Some(ep.id) == peer_id {
|
||||
continue;
|
||||
}
|
||||
let ep_server_allowed = ep.server_wireguard_allowed_ips();
|
||||
let ep_nets: Vec<IpNet> = ep_server_allowed
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||
.collect();
|
||||
|
||||
for n1 in &candidate_nets {
|
||||
for n2 in &ep_nets {
|
||||
if n1.contains(n2) || n2.contains(n1) {
|
||||
return Err(ApiError::Validation(format!(
|
||||
"Server-side AllowedIP '{n1}' overlaps with active peer '{}' AllowedIP '{n2}'",
|
||||
ep.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/v1/interfaces/{id}/peers
|
||||
@@ -182,6 +370,15 @@ pub async fn create_peer_handler(
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// Validate no overlapping server-side AllowedIPs with active peers on the same interface
|
||||
validate_no_server_allowed_ips_conflict(
|
||||
&state.store,
|
||||
interface_id,
|
||||
None,
|
||||
&peer.server_wireguard_allowed_ips(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
state.store.create_peer(&peer).await?;
|
||||
|
||||
state.broadcast(SystemEvent::PeerChanged {
|
||||
@@ -196,13 +393,15 @@ pub async fn create_peer_handler(
|
||||
pub async fn get_peer_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Peer>> {
|
||||
) -> ApiResult<Json<PeerResponse>> {
|
||||
let peer = state
|
||||
.store
|
||||
.get_peer(id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
||||
Ok(Json(peer))
|
||||
let mut enriched = enrich_peers_with_live_telemetry(&state, vec![peer]).await;
|
||||
let peer_resp = enriched.pop().unwrap();
|
||||
Ok(Json(peer_resp))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/peers/{id}
|
||||
@@ -256,6 +455,15 @@ pub async fn update_peer_handler(
|
||||
peer.expires_at = payload.expires_at;
|
||||
}
|
||||
|
||||
// Validate no overlapping server-side AllowedIPs with active peers on the same interface
|
||||
validate_no_server_allowed_ips_conflict(
|
||||
&state.store,
|
||||
peer.interface_id,
|
||||
Some(peer.id),
|
||||
&peer.server_wireguard_allowed_ips(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
state.store.update_peer(&peer).await?;
|
||||
|
||||
state.broadcast(SystemEvent::PeerChanged {
|
||||
@@ -391,6 +599,46 @@ pub struct ClientProfileQuery {
|
||||
pub nat: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub profile: Option<String>,
|
||||
pub server_endpoint: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
}
|
||||
|
||||
async fn resolve_server_endpoint(
|
||||
state: &AppState,
|
||||
query: &ClientProfileQuery,
|
||||
) -> ApiResult<String> {
|
||||
// 1. Explicit query parameter (server_endpoint or endpoint)
|
||||
if let Some(ep) = query
|
||||
.server_endpoint
|
||||
.as_deref()
|
||||
.or(query.endpoint.as_deref())
|
||||
{
|
||||
let trimmed = ep.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Persistent server_endpoint configuration from store
|
||||
if let Some(setting) = state.store.get_setting("server_endpoint").await? {
|
||||
let trimmed = setting.value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Persistent public_endpoint configuration from store
|
||||
if let Some(setting) = state.store.get_setting("public_endpoint").await? {
|
||||
let trimmed = setting.value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit actionable error if no reachable server endpoint is configured
|
||||
Err(ApiError::Validation(
|
||||
"No reachable WireGuard server endpoint is configured. Configure 'server_endpoint' in settings or provide --endpoint / query parameter.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -418,12 +666,7 @@ pub async fn download_peer_config_handler(
|
||||
.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 host = resolve_server_endpoint(&state, &query).await?;
|
||||
|
||||
let resolved_profile = if query.provider.is_some()
|
||||
|| query.device.is_some()
|
||||
@@ -509,12 +752,7 @@ pub async fn get_peer_qr_handler(
|
||||
.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 host = resolve_server_endpoint(&state, &query).await?;
|
||||
|
||||
let resolved_profile = if query.provider.is_some()
|
||||
|| query.device.is_some()
|
||||
|
||||
@@ -94,24 +94,129 @@ pub async fn upsert_setting_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpsertSettingRequest>,
|
||||
) -> ApiResult<Json<GenericSuccess>> {
|
||||
if payload.key.trim().is_empty() {
|
||||
let key_trimmed = payload.key.trim();
|
||||
if key_trimmed.is_empty() {
|
||||
return Err(ApiError::Validation(
|
||||
"Setting key cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let val_trimmed = payload.value.trim();
|
||||
if (key_trimmed == "server_endpoint" || key_trimmed == "public_endpoint")
|
||||
&& !val_trimmed.is_empty()
|
||||
{
|
||||
let has_valid_port = if let Some(last_colon) = val_trimmed.rfind(':') {
|
||||
let port_str = &val_trimmed[last_colon + 1..];
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
port > 0 && !val_trimmed[..last_colon].trim().is_empty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !has_valid_port {
|
||||
return Err(ApiError::Validation(format!(
|
||||
"Invalid server endpoint '{val_trimmed}'. Endpoint must be formatted as host:port (e.g. 192.168.1.8:51820 or vpn.domain.com:51820)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let is_secret = payload.is_secret.unwrap_or(false);
|
||||
state
|
||||
.store
|
||||
.set_setting(&payload.key, &payload.value, is_secret)
|
||||
.set_setting(key_trimmed, val_trimmed, is_secret)
|
||||
.await?;
|
||||
|
||||
state.broadcast(SystemEvent::SettingsChanged {
|
||||
key: payload.key.clone(),
|
||||
key: key_trimmed.to_string(),
|
||||
});
|
||||
|
||||
Ok(Json(GenericSuccess {
|
||||
success: true,
|
||||
message: format!("Setting '{}' saved successfully", payload.key),
|
||||
message: format!("Setting '{key_trimmed}' saved"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LiveInterfaceTelemetry {
|
||||
pub name: String,
|
||||
pub public_key: String,
|
||||
pub listen_port: u16,
|
||||
pub fwmark: u32,
|
||||
pub addresses: Vec<String>,
|
||||
pub mtu: Option<u32>,
|
||||
pub is_up: bool,
|
||||
pub peer_count: usize,
|
||||
pub peers: Vec<nx9_wireguard::LivePeerStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LiveRouteTelemetry {
|
||||
pub destination: String,
|
||||
pub gateway: Option<String>,
|
||||
pub metric: Option<u32>,
|
||||
pub table: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LiveSystemState {
|
||||
pub interfaces: Vec<LiveInterfaceTelemetry>,
|
||||
pub routes: Vec<LiveRouteTelemetry>,
|
||||
pub ipv4_forwarding: bool,
|
||||
pub ipv6_forwarding: bool,
|
||||
pub active_nftables: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/v1/system/live-state
|
||||
pub async fn live_state_handler(State(state): State<AppState>) -> ApiResult<Json<LiveSystemState>> {
|
||||
let iface_names = state.wg_engine.list_interfaces().await.unwrap_or_default();
|
||||
let mut interfaces = Vec::new();
|
||||
|
||||
for name in iface_names {
|
||||
if let Ok(Some(stats)) = state.wg_engine.get_interface_stats(&name).await {
|
||||
let peer_count = stats.peers.len();
|
||||
interfaces.push(LiveInterfaceTelemetry {
|
||||
name: stats.name,
|
||||
public_key: stats.public_key,
|
||||
listen_port: stats.listen_port,
|
||||
fwmark: stats.fwmark,
|
||||
addresses: stats.addresses,
|
||||
mtu: stats.mtu,
|
||||
is_up: stats.is_up,
|
||||
peer_count,
|
||||
peers: stats.peers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let desired_routes = state.store.list_routes().await.unwrap_or_default();
|
||||
let routes = desired_routes
|
||||
.into_iter()
|
||||
.filter(|r| r.enabled)
|
||||
.map(|r| LiveRouteTelemetry {
|
||||
destination: r.destination.to_string(),
|
||||
gateway: r.gateway.map(|g| g.to_string()),
|
||||
metric: r.metric,
|
||||
table: 254,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let fwd = state.net_engine.get_forwarding_status().await.unwrap_or(
|
||||
nx9_wg_network::IpForwardingStatus {
|
||||
ipv4_enabled: false,
|
||||
ipv6_enabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
let active_nftables = state.net_engine.get_active_nftables_ruleset().await.ok();
|
||||
|
||||
Ok(Json(LiveSystemState {
|
||||
interfaces,
|
||||
routes,
|
||||
ipv4_forwarding: fwd.ipv4_enabled,
|
||||
ipv6_forwarding: fwd.ipv6_enabled,
|
||||
active_nftables,
|
||||
}))
|
||||
}
|
||||
@@ -3,7 +3,14 @@
|
||||
use crate::auth::service::AuthService;
|
||||
use nx9_wg_core::types::audit::AuditEventType;
|
||||
use nx9_wg_db::Store;
|
||||
#[allow(unused_imports)]
|
||||
use nx9_wg_network::engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
|
||||
#[allow(unused_imports)]
|
||||
use nx9_wireguard::engine::{
|
||||
NativeLinuxWireGuardEngine, SimulatedWireGuardEngine, WireGuardEngine,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Real-time system event broadcasted over WebSocket to connected clients.
|
||||
@@ -39,17 +46,41 @@ pub struct AppState {
|
||||
pub store: Store,
|
||||
pub auth: AuthService,
|
||||
pub event_tx: broadcast::Sender<SystemEvent>,
|
||||
pub wg_engine: Arc<dyn WireGuardEngine>,
|
||||
pub net_engine: Arc<dyn NetworkEngine>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Create a new AppState instance.
|
||||
/// Create a new AppState instance with default engines.
|
||||
pub fn new(store: Store) -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
let (wg, net): (Arc<dyn WireGuardEngine>, Arc<dyn NetworkEngine>) = (
|
||||
Arc::new(NativeLinuxWireGuardEngine::new()),
|
||||
Arc::new(NativeLinuxNetworkEngine::new()),
|
||||
);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let (wg, net): (Arc<dyn WireGuardEngine>, Arc<dyn NetworkEngine>) = (
|
||||
Arc::new(SimulatedWireGuardEngine::new()),
|
||||
Arc::new(SimulatedNetworkEngine::new()),
|
||||
);
|
||||
|
||||
Self::with_engines(store, wg, net)
|
||||
}
|
||||
|
||||
/// Create a new AppState instance with custom engines.
|
||||
pub fn with_engines(
|
||||
store: Store,
|
||||
wg_engine: Arc<dyn WireGuardEngine>,
|
||||
net_engine: Arc<dyn NetworkEngine>,
|
||||
) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
let auth = AuthService::new(store.clone());
|
||||
Self {
|
||||
store,
|
||||
auth,
|
||||
event_tx,
|
||||
wg_engine,
|
||||
net_engine,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,3 +253,54 @@ async fn test_auth_service_api_tokens() {
|
||||
"revoked token must fail authentication"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_service_logout_invalidates_session_and_is_idempotent() {
|
||||
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 2 sessions
|
||||
let s1 = auth
|
||||
.login("admin", "AdminSecret123!", Some("10.0.0.1"), None)
|
||||
.await
|
||||
.expect("login 1");
|
||||
let s2 = auth
|
||||
.login("admin", "AdminSecret123!", Some("10.0.0.2"), None)
|
||||
.await
|
||||
.expect("login 2");
|
||||
|
||||
assert!(auth.authenticate_session(&s1.id).await.is_ok());
|
||||
assert!(auth.authenticate_session(&s2.id).await.is_ok());
|
||||
|
||||
// Logout session 1
|
||||
auth.logout(&s1.id, Some("10.0.0.1"))
|
||||
.await
|
||||
.expect("logout s1");
|
||||
|
||||
// Session 1 is invalidated; Session 2 remains valid
|
||||
assert!(
|
||||
auth.authenticate_session(&s1.id).await.is_err(),
|
||||
"s1 must be rejected after logout"
|
||||
);
|
||||
assert!(
|
||||
auth.authenticate_session(&s2.id).await.is_ok(),
|
||||
"s2 must remain valid"
|
||||
);
|
||||
|
||||
// Repeated logout of s1 is safe/idempotent
|
||||
assert!(
|
||||
auth.logout(&s1.id, Some("10.0.0.1")).await.is_ok(),
|
||||
"repeated logout must be safe and idempotent"
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,10 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
||||
updated_at: now,
|
||||
};
|
||||
store.create_peer(&peer).await.unwrap();
|
||||
store
|
||||
.set_setting("server_endpoint", "vpn.example.com", false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = AppState::new(store);
|
||||
let app = nx9_wg_api::routes::build_api_router(state.clone());
|
||||
@@ -88,7 +92,7 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_profiles_endpoints() {
|
||||
let (app, _state, session_id, _iface, peer) = setup_test_app().await;
|
||||
let (app, state, session_id, interface, peer) = setup_test_app().await;
|
||||
|
||||
// 1. List client profiles
|
||||
let req = Request::builder()
|
||||
@@ -181,4 +185,67 @@ async fn test_client_profiles_endpoints() {
|
||||
.unwrap();
|
||||
let qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
|
||||
|
||||
// 7. Delete server_endpoint setting and verify config export fails with actionable error
|
||||
state.store.delete_setting("server_endpoint").await.unwrap();
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", 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::UNPROCESSABLE_ENTITY);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(
|
||||
err_json["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("No reachable WireGuard server endpoint is configured")
|
||||
);
|
||||
|
||||
// 8. With query server_endpoint parameter, export succeeds even without DB setting
|
||||
let req = Request::builder()
|
||||
.uri(format!(
|
||||
"/api/v1/peers/{}/config?server_endpoint=custom.vpn.io:51820",
|
||||
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("Endpoint = custom.vpn.io:51820"));
|
||||
|
||||
// 9. Overlapping server-side AllowedIPs rejection
|
||||
let overlap_peer = serde_json::json!({
|
||||
"name": "overlapping-peer",
|
||||
"peer_type": "road_warrior",
|
||||
"address_v4": "10.0.0.2/32"
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{}/peers", interface.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&overlap_peer).unwrap()))
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(
|
||||
err_json["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("overlaps with active peer")
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use nx9_wg_core::types::network::Route;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
||||
use std::sync::Arc;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
@@ -399,3 +399,187 @@ async fn test_reconciliation_status_lifecycle_and_multi_cycle_idempotency() {
|
||||
assert!(!plan.has_drift, "Cycle {cycle} plan must show zero drift");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reconciliation_report_schema_and_json_contract() {
|
||||
use nx9_wg_api::reconciliation::{ReconciliationReport, ReconciliationStatus};
|
||||
|
||||
let report = ReconciliationReport {
|
||||
success: true,
|
||||
status: ReconciliationStatus::Converged,
|
||||
executed_actions: 3,
|
||||
failed_actions: 0,
|
||||
details: vec![
|
||||
"Synchronized interface 'wg0' with 5 peers".to_string(),
|
||||
"Synchronized 1 routing entries".to_string(),
|
||||
"Synchronized 0 firewall rules into table inet nx9_wg (NAT: true)".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
let json_val = serde_json::to_value(&report).unwrap();
|
||||
assert_eq!(json_val["success"], true);
|
||||
assert_eq!(json_val["status"], "converged");
|
||||
assert_eq!(json_val["executed_actions"], 3);
|
||||
assert_eq!(json_val["failed_actions"], 0);
|
||||
assert!(json_val["details"].is_array());
|
||||
assert_eq!(json_val["details"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reconciliation_nftables_canonical_drift_and_kernel_handle_tolerance() {
|
||||
let (_dir, store, _state, _wg_engine, net_engine, reconciler) = setup_test_env().await;
|
||||
|
||||
// Add firewall rule in SQLite
|
||||
let fw = FirewallRule {
|
||||
id: Uuid::new_v4(),
|
||||
name: "allow-https".to_string(),
|
||||
interface_id: None,
|
||||
peer_id: None,
|
||||
direction: FirewallDirection::In,
|
||||
source: None,
|
||||
destination: None,
|
||||
protocol: FirewallProtocol::Tcp,
|
||||
source_port: None,
|
||||
destination_port: Some(443),
|
||||
port_range: None,
|
||||
action: FirewallAction::Accept,
|
||||
priority: 50,
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
store.create_firewall_rule(&fw).await.unwrap();
|
||||
|
||||
// 1. Initial Plan should detect drift
|
||||
let plan = reconciler.plan().await.unwrap();
|
||||
assert!(plan.has_drift);
|
||||
assert_eq!(plan.firewall_changes, 1);
|
||||
|
||||
// 2. Apply should converge
|
||||
let report = reconciler.apply().await.unwrap();
|
||||
assert!(report.success);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||
);
|
||||
assert_eq!(report.failed_actions, 0);
|
||||
|
||||
// 3. Post-apply verify: exactly 0 drift
|
||||
let plan_after = reconciler.plan().await.unwrap();
|
||||
assert!(!plan_after.has_drift);
|
||||
assert_eq!(plan_after.firewall_changes, 0);
|
||||
|
||||
// 4. Simulate kernel returning ruleset with handles and tabs
|
||||
let simulated_kernel_output_with_handles = r#"table inet nx9_wg {
|
||||
chain input {
|
||||
type filter hook input priority filter; policy accept;
|
||||
ct state established,related accept # handle 46
|
||||
iifname "lo" accept # handle 1
|
||||
tcp dport 443 accept # handle 10
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
ct state established,related accept # handle 4
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
// Set simulated ruleset to text containing kernel handles
|
||||
net_engine
|
||||
.sync_firewall(std::slice::from_ref(&fw), false, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Directly test drift function against simulated kernel handles
|
||||
let expected = nx9_wg_network::NftablesRulesetBuilder::build(&[fw], false, &[]);
|
||||
assert!(
|
||||
!nx9_wg_network::has_nftables_drift(&expected, simulated_kernel_output_with_handles),
|
||||
"Ruleset with handles must not trigger false drift"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interface_address_and_mtu_drift_lifecycle() {
|
||||
let (_dir, store, _state, wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
||||
|
||||
let (priv_key, pub_key) = generate_keypair();
|
||||
let iface_id = Uuid::new_v4();
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg0".to_string(),
|
||||
private_key: priv_key,
|
||||
public_key: pub_key.clone(),
|
||||
listen_port: 51820,
|
||||
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
|
||||
address_v6: Some(validate_cidr("fd00::1/64").unwrap()),
|
||||
mtu: Some(1420),
|
||||
dns: None,
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
store.create_interface(&iface).await.unwrap();
|
||||
|
||||
// 1. Initially, interface does not exist in wg_engine -> plan reports create_interface drift
|
||||
let plan = reconciler.plan().await.unwrap();
|
||||
assert!(plan.has_drift);
|
||||
assert_eq!(plan.interface_changes, 1);
|
||||
assert_eq!(plan.actions[0].action_type, "create_interface");
|
||||
|
||||
// 2. Apply initial sync -> interface is created and synchronized
|
||||
let report = reconciler.apply().await.unwrap();
|
||||
assert!(report.success);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||
);
|
||||
|
||||
// 3. Post-apply plan must have 0 drift
|
||||
let plan_after = reconciler.plan().await.unwrap();
|
||||
assert!(!plan_after.has_drift);
|
||||
assert_eq!(plan_after.interface_changes, 0);
|
||||
|
||||
// 4. Manually strip IPv4 address from live interface to simulate kernel address drop
|
||||
let mut stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap();
|
||||
stats.addresses = vec!["fd00::1/64".to_string()]; // IPv4 missing
|
||||
// Sync altered stats
|
||||
wg_engine
|
||||
.sync_interface(
|
||||
&Interface {
|
||||
address_v4: validate_cidr("10.99.99.99/24").unwrap(), // different
|
||||
..iface.clone()
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 5. Plan MUST detect the missing/mismatched IPv4 address as drift
|
||||
let plan_drift = reconciler.plan().await.unwrap();
|
||||
assert!(plan_drift.has_drift);
|
||||
assert_eq!(plan_drift.interface_changes, 1);
|
||||
assert_eq!(plan_drift.actions[0].action_type, "update_interface");
|
||||
assert!(plan_drift.actions[0].description.contains("IPv4 address"));
|
||||
|
||||
// 6. Apply reconciliation -> restores correct addresses
|
||||
let report2 = reconciler.apply().await.unwrap();
|
||||
assert!(report2.success);
|
||||
assert_eq!(
|
||||
report2.status,
|
||||
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||
);
|
||||
|
||||
// 7. Final plan reports 0 drift
|
||||
let final_plan = reconciler.plan().await.unwrap();
|
||||
assert!(!final_plan.has_drift);
|
||||
assert_eq!(final_plan.interface_changes, 0);
|
||||
}
|
||||
@@ -234,3 +234,178 @@ async fn test_networks_and_firewall_rest_lifecycle() {
|
||||
assert_eq!(rule_val["name"], "Allow HTTPS");
|
||||
assert_eq!(rule_val["priority"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_peers_collection_endpoint() {
|
||||
let (app, cookie) = setup_test_app().await;
|
||||
|
||||
// 1. Verify unauthenticated GET /api/v1/peers returns 401 Unauthorized
|
||||
let unauth_req = Request::builder()
|
||||
.uri("/api/v1/peers")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let unauth_resp = app.clone().oneshot(unauth_req).await.unwrap();
|
||||
assert_eq!(unauth_resp.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// 2. Create first interface (wg0)
|
||||
let iface0_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.100.0.1/24"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let iface0_resp = app.clone().oneshot(iface0_req).await.unwrap();
|
||||
assert_eq!(iface0_resp.status(), StatusCode::OK);
|
||||
let iface0_val: Value =
|
||||
serde_json::from_slice(&to_bytes(iface0_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
let iface0_id = iface0_val["id"].as_str().unwrap();
|
||||
|
||||
// 3. Create second interface (wg1)
|
||||
let iface1_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "wg1",
|
||||
"listen_port": 51821,
|
||||
"address_v4": "10.200.0.1/24"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let iface1_resp = app.clone().oneshot(iface1_req).await.unwrap();
|
||||
assert_eq!(iface1_resp.status(), StatusCode::OK);
|
||||
let iface1_val: Value =
|
||||
serde_json::from_slice(&to_bytes(iface1_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
let iface1_id = iface1_val["id"].as_str().unwrap();
|
||||
|
||||
// 4. Create 2 peers under wg0
|
||||
let peer_alice_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "peer-alice",
|
||||
"allowed_ips": "10.100.0.2/32"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp_alice = app.clone().oneshot(peer_alice_req).await.unwrap();
|
||||
assert_eq!(resp_alice.status(), StatusCode::OK);
|
||||
|
||||
let peer_bob_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "peer-bob",
|
||||
"allowed_ips": "10.100.0.3/32"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp_bob = app.clone().oneshot(peer_bob_req).await.unwrap();
|
||||
assert_eq!(resp_bob.status(), StatusCode::OK);
|
||||
|
||||
// 5. Create 1 peer under wg1
|
||||
let peer_charlie_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "peer-charlie",
|
||||
"allowed_ips": "10.200.0.2/32"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp_charlie = app.clone().oneshot(peer_charlie_req).await.unwrap();
|
||||
assert_eq!(resp_charlie.status(), StatusCode::OK);
|
||||
|
||||
// 6. Test GET /api/v1/peers (All peers across all interfaces)
|
||||
let list_all_req = Request::builder()
|
||||
.uri("/api/v1/peers")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let list_all_resp = app.clone().oneshot(list_all_req).await.unwrap();
|
||||
assert_eq!(list_all_resp.status(), StatusCode::OK);
|
||||
|
||||
let all_peers_bytes = to_bytes(list_all_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let all_peers: Vec<Value> = serde_json::from_slice(&all_peers_bytes).unwrap();
|
||||
assert_eq!(
|
||||
all_peers.len(),
|
||||
3,
|
||||
"GET /api/v1/peers must return all 3 peers across both interfaces"
|
||||
);
|
||||
|
||||
let peer_names: Vec<&str> = all_peers
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(peer_names.contains(&"peer-alice"));
|
||||
assert!(peer_names.contains(&"peer-bob"));
|
||||
assert!(peer_names.contains(&"peer-charlie"));
|
||||
|
||||
// Verify representative fields are present and valid
|
||||
for p in &all_peers {
|
||||
assert!(p["id"].is_string());
|
||||
assert!(p["public_key"].is_string());
|
||||
assert!(p["interface_id"].is_string());
|
||||
assert!(p["state"].is_string());
|
||||
assert!(p["allowed_ips"].is_string());
|
||||
}
|
||||
|
||||
// 7. Verify interface-scoped endpoint still works and returns only that interface's peers
|
||||
let list_iface0_req = Request::builder()
|
||||
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let iface0_peers_resp = app.clone().oneshot(list_iface0_req).await.unwrap();
|
||||
assert_eq!(iface0_peers_resp.status(), StatusCode::OK);
|
||||
let iface0_peers: Vec<Value> = serde_json::from_slice(
|
||||
&to_bytes(iface0_peers_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(iface0_peers.len(), 2, "wg0 must return exactly 2 peers");
|
||||
|
||||
let list_iface1_req = Request::builder()
|
||||
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let iface1_peers_resp = app.oneshot(list_iface1_req).await.unwrap();
|
||||
assert_eq!(iface1_peers_resp.status(), StatusCode::OK);
|
||||
let iface1_peers: Vec<Value> = serde_json::from_slice(
|
||||
&to_bytes(iface1_peers_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(iface1_peers.len(), 1, "wg1 must return exactly 1 peer");
|
||||
assert_eq!(iface1_peers[0]["name"], "peer-charlie");
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Comprehensive tests for WireGuard road-warrior data-plane, cryptokey routing,
|
||||
//! endpoint resolution, telemetry ingestion, and reconciliation invariants.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
use nx9_wireguard::{
|
||||
ClientConfigBuilder, LiveInterfaceStats, LivePeerStats, SimulatedWireGuardEngine,
|
||||
WireGuardEngine,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn setup_test_context() -> (AppState, Interface, Peer, String) {
|
||||
let store = Store::connect_in_memory().await.unwrap();
|
||||
store.migrate().await.unwrap();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let hash = nx9_wg_core::crypto::hash_password("testadminpass123").unwrap();
|
||||
store.create_admin("admin", &hash).await.unwrap();
|
||||
|
||||
let session = nx9_wg_core::types::auth::Session {
|
||||
id: "test-dataplane-session-id".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.100.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1, 1.0.0.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: "Mobile".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: "0.0.0.0/0, ::/0".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.100.0.9/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: Some("1.1.1.1, 1.0.0.1".to_string()),
|
||||
mtu: Some(1420),
|
||||
persistent_keepalive: Some(25),
|
||||
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);
|
||||
(state, interface, peer, session.id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_road_warrior_server_allowed_ips_vs_client_full_tunnel() {
|
||||
let (_state, iface, peer, _session_id) = setup_test_context().await;
|
||||
|
||||
// 1. Server-side WireGuard peer AllowedIPs MUST be strictly the assigned client IP (10.100.0.9/32)
|
||||
assert_eq!(peer.server_wireguard_allowed_ips(), "10.100.0.9/32");
|
||||
|
||||
// 2. Client configuration MUST contain the FullTunnel routing policy (0.0.0.0/0 for IPv4-only server)
|
||||
let conf = ClientConfigBuilder::build(&peer, &iface, "192.168.1.8:51820").unwrap();
|
||||
assert!(conf.contains("Address = 10.100.0.9/32"));
|
||||
assert!(conf.contains("AllowedIPs = 0.0.0.0/0"));
|
||||
assert!(conf.contains("Endpoint = 192.168.1.8:51820"));
|
||||
assert!(conf.contains("PersistentKeepalive = 25"));
|
||||
|
||||
// Dual-stack interface exports dual-stack full tunnel
|
||||
let mut dual_iface = iface.clone();
|
||||
dual_iface.address_v6 = Some(IpNet::from_str("fd00::1/64").unwrap());
|
||||
let dual_conf = ClientConfigBuilder::build(&peer, &dual_iface, "192.168.1.8:51820").unwrap();
|
||||
assert!(dual_conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_endpoint_resolution_failure_and_override() {
|
||||
let (state, _iface, peer, session_id) = setup_test_context().await;
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// 1. Config export without persistent setting or query endpoint fails with 422
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", 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::UNPROCESSABLE_ENTITY);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(
|
||||
err_json["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("No reachable WireGuard server endpoint is configured")
|
||||
);
|
||||
|
||||
// 2. Setting persistent server_endpoint setting succeeds
|
||||
state
|
||||
.store
|
||||
.set_setting("server_endpoint", "192.168.1.8:51820", false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", 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("Endpoint = 192.168.1.8:51820"));
|
||||
|
||||
// 3. Explicit query parameter overrides persistent setting
|
||||
let req = Request::builder()
|
||||
.uri(format!(
|
||||
"/api/v1/peers/{}/config?server_endpoint=vpn.publicdomain.org:51820",
|
||||
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("Endpoint = vpn.publicdomain.org:51820"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_learned_endpoint_and_handshake_telemetry_ingestion() {
|
||||
let (state, iface, peer, _session_id) = setup_test_context().await;
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
|
||||
// Sync initial state to simulated engine
|
||||
wg_engine
|
||||
.sync_interface(&iface, std::slice::from_ref(&peer))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
|
||||
// Initially peer has no learned endpoint or handshake in DB
|
||||
let p_db = state.store.get_peer(peer.id).await.unwrap().unwrap();
|
||||
assert!(p_db.endpoint.is_none());
|
||||
assert!(p_db.last_handshake_at.is_none());
|
||||
|
||||
// Simulate incoming authenticated handshake from client
|
||||
let hs_time = Utc::now().naive_utc();
|
||||
let learned_client_ep = "192.168.1.50:41234".to_string();
|
||||
|
||||
// Apply initial baseline state to ensure all subsystems start converged
|
||||
let initial_report = reconciler.apply().await.unwrap();
|
||||
assert!(initial_report.success);
|
||||
|
||||
// Directly simulate kernel stats containing learned endpoint and handshake
|
||||
let live_peers = vec![LivePeerStats {
|
||||
public_key: peer.public_key.as_str().to_string(),
|
||||
endpoint: Some(learned_client_ep.clone()),
|
||||
rx_bytes: 1024,
|
||||
tx_bytes: 2048,
|
||||
last_handshake_at: Some(hs_time),
|
||||
allowed_ips: vec!["10.100.0.9/32".to_string()],
|
||||
persistent_keepalive: Some(25),
|
||||
}];
|
||||
wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.as_str().to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
fwmark: 0,
|
||||
peers: live_peers,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Run reconciliation plan — should ingest telemetry without peer drift
|
||||
let plan = reconciler.plan().await.unwrap();
|
||||
assert_eq!(plan.peer_changes, 0);
|
||||
|
||||
// Verify learned telemetry was ingested into the SQLite store
|
||||
let updated_peer = state.store.get_peer(peer.id).await.unwrap().unwrap();
|
||||
assert_eq!(updated_peer.endpoint.as_deref(), Some("192.168.1.50:41234"));
|
||||
assert_eq!(
|
||||
updated_peer
|
||||
.last_handshake_at
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp(),
|
||||
hs_time.and_utc().timestamp()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_peer_allowed_ips_and_keepalive_kernel_drift() {
|
||||
let (state, iface, peer, _session_id) = setup_test_context().await;
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
|
||||
// Inject drift: kernel peer erroneously has 0.0.0.0/0 as AllowedIPs and keepalive = 10s
|
||||
let drifted_peers = vec![LivePeerStats {
|
||||
public_key: peer.public_key.as_str().to_string(),
|
||||
endpoint: None,
|
||||
rx_bytes: 0,
|
||||
tx_bytes: 0,
|
||||
last_handshake_at: None,
|
||||
allowed_ips: vec!["0.0.0.0/0".to_string(), "::/0".to_string()],
|
||||
persistent_keepalive: Some(10),
|
||||
}];
|
||||
wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.as_str().to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
fwmark: 0,
|
||||
peers: drifted_peers,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Reconciliation plan MUST detect this semantic drift
|
||||
let plan = reconciler.plan().await.unwrap();
|
||||
assert!(plan.has_drift);
|
||||
assert_eq!(plan.peer_changes, 1);
|
||||
assert!(
|
||||
plan.actions
|
||||
.iter()
|
||||
.any(|a| a.action_type == "update_peer" && a.description.contains("AllowedIPs drift"))
|
||||
);
|
||||
|
||||
// Execute reconciliation apply
|
||||
let report = reconciler.apply().await.unwrap();
|
||||
assert!(report.success);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||
);
|
||||
|
||||
// Verify post-apply convergence: zero drift
|
||||
let post_plan = reconciler.plan().await.unwrap();
|
||||
assert!(!post_plan.has_drift);
|
||||
assert_eq!(post_plan.peer_changes, 0);
|
||||
|
||||
// Verify live kernel stats now match desired server AllowedIPs (10.100.0.9/32)
|
||||
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap();
|
||||
assert_eq!(live_stats.peers[0].allowed_ips, vec!["10.100.0.9/32"]);
|
||||
assert_eq!(live_stats.peers[0].persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_and_nat_reconciliation_invariants() {
|
||||
let (state, _iface, _peer, _session_id) = setup_test_context().await;
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
|
||||
// Enable NAT masquerade in settings
|
||||
state
|
||||
.store
|
||||
.set_setting("nat_enabled", "true", false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Reconcile apply
|
||||
let report = reconciler.apply().await.unwrap();
|
||||
assert!(report.success);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||
);
|
||||
|
||||
// Verify NAT masquerade is active in network engine for 10.100.0.0/24 subnet
|
||||
let ruleset = net_engine.get_active_nftables_ruleset().await.unwrap();
|
||||
assert!(ruleset.contains("masquerade"));
|
||||
assert!(ruleset.contains("10.100.0.0/24"));
|
||||
|
||||
// Re-planning shows 0 drift
|
||||
let plan = reconciler.plan().await.unwrap();
|
||||
assert!(!plan.has_drift);
|
||||
assert_eq!(plan.firewall_changes, 0);
|
||||
assert_eq!(plan.route_changes, 0);
|
||||
assert_eq!(plan.interface_changes, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interface_editing_persistence_and_key_preservation() {
|
||||
let (state, iface, _peer, session_id) = setup_test_context().await;
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
let orig_priv_key = iface.private_key.clone();
|
||||
let orig_pub_key = iface.public_key.clone();
|
||||
let orig_id = iface.id;
|
||||
|
||||
// 1. Edit interface wg0 (change address_v4, listen_port, MTU, DNS, enabled)
|
||||
let update_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/v1/interfaces/{}", iface.id))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"name": "wg0",
|
||||
"address_v4": "10.200.0.1/24",
|
||||
"listen_port": 51822,
|
||||
"mtu": 1360,
|
||||
"dns": "9.9.9.9",
|
||||
"enabled": true
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(update_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 2. Query updated interface from database
|
||||
let updated_iface = state.store.get_interface(orig_id).await.unwrap().unwrap();
|
||||
assert_eq!(updated_iface.address_v4.to_string(), "10.200.0.1/24");
|
||||
assert_eq!(updated_iface.listen_port, 51822);
|
||||
assert_eq!(updated_iface.mtu, Some(1360));
|
||||
assert_eq!(updated_iface.dns, Some("9.9.9.9".to_string()));
|
||||
|
||||
// 3. Verify private key, public key, and ID were strictly preserved (NEVER regenerated)
|
||||
assert_eq!(updated_iface.id, orig_id);
|
||||
assert_eq!(updated_iface.private_key.as_str(), orig_priv_key.as_str());
|
||||
assert_eq!(updated_iface.public_key.as_str(), orig_pub_key.as_str());
|
||||
|
||||
// 4. Verify peers attached to wg0 were preserved
|
||||
let peers = state.store.list_peers_for_interface(orig_id).await.unwrap();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].name, "Mobile");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_endpoint_persistence_validation_and_export_precedence() {
|
||||
let (state, _iface, peer, session_id) = setup_test_context().await;
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// 1. Invalid server_endpoint format (missing port) is rejected with 422
|
||||
let invalid_setting_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "server_endpoint",
|
||||
"value": "192.168.1.8", // missing port!
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(invalid_setting_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// 2. Valid server_endpoint saves successfully
|
||||
let valid_setting_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "server_endpoint",
|
||||
"value": "192.168.1.8:51820",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(valid_setting_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 3. Export config without override consumes the persisted setting automatically
|
||||
let export_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(export_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
|
||||
assert!(conf_str.contains("Endpoint = 192.168.1.8:51820"));
|
||||
|
||||
// 4. Export QR code returns JSON with SVG and data_url containing the same endpoint
|
||||
let qr_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/qr", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(qr_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let qr_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let qr_json: serde_json::Value = serde_json::from_slice(&qr_bytes).unwrap();
|
||||
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
|
||||
assert!(
|
||||
qr_json["data_url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
|
||||
// 5. Explicit override query parameter takes precedence over setting
|
||||
let override_req = Request::builder()
|
||||
.uri(format!(
|
||||
"/api/v1/peers/{}/config?endpoint=vpn.wan-domain.org:51820",
|
||||
peer.id
|
||||
))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(override_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
|
||||
assert!(conf_str.contains("Endpoint = vpn.wan-domain.org:51820"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
||||
let (state_orig, iface, peer, session_id) = setup_test_context().await;
|
||||
|
||||
let simulated_wg = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let simulated_net = Arc::new(SimulatedNetworkEngine::new());
|
||||
let state = AppState::with_engines(
|
||||
state_orig.store.clone(),
|
||||
simulated_wg.clone(),
|
||||
simulated_net.clone(),
|
||||
);
|
||||
|
||||
// Inject live kernel statistics into simulated WireGuard engine
|
||||
let recent_hs = Utc::now().naive_utc() - chrono::Duration::seconds(15);
|
||||
let live_peer = LivePeerStats {
|
||||
public_key: peer.public_key.to_string(),
|
||||
endpoint: Some("192.168.1.50:41234".to_string()),
|
||||
rx_bytes: 409600,
|
||||
tx_bytes: 819200,
|
||||
last_handshake_at: Some(recent_hs),
|
||||
allowed_ips: vec!["10.100.0.9/32".to_string()],
|
||||
persistent_keepalive: Some(25),
|
||||
};
|
||||
|
||||
let live_iface = LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
fwmark: 0,
|
||||
peers: vec![live_peer],
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
};
|
||||
|
||||
// Inject live stats into simulated_wg
|
||||
simulated_wg.inject_interface_stats(live_iface).await;
|
||||
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Query GET /api/v1/peers
|
||||
let list_req = Request::builder()
|
||||
.uri("/api/v1/peers")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(list_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let peers_json: Vec<serde_json::Value> = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(peers_json.len(), 1);
|
||||
|
||||
let p = &peers_json[0];
|
||||
assert_eq!(p["name"], "Mobile");
|
||||
assert_eq!(p["endpoint"], "192.168.1.50:41234");
|
||||
assert_eq!(p["rx_bytes"], 409600);
|
||||
assert_eq!(p["tx_bytes"], 819200);
|
||||
|
||||
// Handshake is serialized as explicit RFC3339 UTC string with offset/Z
|
||||
let hs_str = p["last_handshake_at"].as_str().unwrap();
|
||||
assert!(hs_str.contains('T'));
|
||||
assert!(hs_str.ends_with('Z') || hs_str.contains("+00:00"));
|
||||
|
||||
// Verify learned telemetry was cached in SQLite
|
||||
let db_peer = state.store.get_peer(peer.id).await.unwrap().unwrap();
|
||||
assert_eq!(db_peer.endpoint, Some("192.168.1.50:41234".to_string()));
|
||||
assert_eq!(
|
||||
db_peer.last_handshake_at.unwrap().and_utc().timestamp(),
|
||||
recent_hs.and_utc().timestamp()
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,24 @@ use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_db::Store;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn setup_test_app() -> (axum::Router, Store) {
|
||||
let store = Store::connect_in_memory().await.expect("connect store");
|
||||
store.migrate().await.expect("migrate store");
|
||||
|
||||
let config = nx9_wg_core::config::AppConfig::default();
|
||||
let opts = nx9_wg_api::auth::BootstrapOptions {
|
||||
cli_password: Some("TestAdminPassword123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
nx9_wg_api::auth::bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap admin");
|
||||
|
||||
let state = AppState::new(store.clone());
|
||||
let app = build_api_router(state);
|
||||
(app, store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
||||
let store = Store::connect_in_memory().await.expect("connect store");
|
||||
@@ -121,6 +139,11 @@ async fn test_ui_api_complete_functional_loop() {
|
||||
.await
|
||||
.expect("bootstrap admin");
|
||||
|
||||
store
|
||||
.set_setting("server_endpoint", "vpn.example.com", false)
|
||||
.await
|
||||
.expect("set server_endpoint");
|
||||
|
||||
let state = AppState::new(store.clone());
|
||||
let app = build_api_router(state);
|
||||
|
||||
@@ -227,6 +250,26 @@ async fn test_ui_api_complete_functional_loop() {
|
||||
let peer_json: serde_json::Value = serde_json::from_slice(&peer_body).unwrap();
|
||||
let peer_id = peer_json["id"].as_str().unwrap();
|
||||
|
||||
// 4b. UI fetches collection of all peers (Peers page render: GET /api/v1/peers)
|
||||
let res_all_peers = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/peers")
|
||||
.header(axum::http::header::COOKIE, &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("get all peers");
|
||||
assert_eq!(res_all_peers.status(), StatusCode::OK);
|
||||
let all_peers_bytes = to_bytes(res_all_peers.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let all_peers_json: Vec<serde_json::Value> = serde_json::from_slice(&all_peers_bytes).unwrap();
|
||||
assert_eq!(all_peers_json.len(), 1);
|
||||
assert_eq!(all_peers_json[0]["name"], "alice-phone");
|
||||
|
||||
// 5. UI downloads Client Config & SVG QR Code
|
||||
let res_conf = app
|
||||
.clone()
|
||||
@@ -452,6 +495,7 @@ async fn test_ui_api_complete_functional_loop() {
|
||||
|
||||
// 13. UI Logout
|
||||
let res_logout = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
@@ -463,4 +507,205 @@ async fn test_ui_api_complete_functional_loop() {
|
||||
.await
|
||||
.expect("logout request");
|
||||
assert_eq!(res_logout.status(), StatusCode::OK);
|
||||
|
||||
// 14. Post-Logout: Session must be completely rejected on protected endpoints
|
||||
let res_post_logout = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/auth/session")
|
||||
.header(axum::http::header::COOKIE, &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("post-logout session request");
|
||||
assert_eq!(res_post_logout.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_logout_session_invalidation_and_idempotency() {
|
||||
let (app, _store) = setup_test_app().await;
|
||||
|
||||
// 1. Initial login
|
||||
let login_body = serde_json::to_vec(&serde_json::json!({
|
||||
"username": "admin",
|
||||
"password": "TestAdminPassword123!"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let res_login = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(login_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("login request");
|
||||
assert_eq!(res_login.status(), StatusCode::OK);
|
||||
|
||||
let cookie_header = res_login
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
.expect("Set-Cookie header present")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let session_cookie = cookie_header
|
||||
.split(';')
|
||||
.next()
|
||||
.expect("nx9_session cookie")
|
||||
.to_string();
|
||||
|
||||
// 2. Verified access before logout
|
||||
let res_auth_session = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/auth/session")
|
||||
.header(axum::http::header::COOKIE, &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res_auth_session.status(), StatusCode::OK);
|
||||
|
||||
let res_system = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/system")
|
||||
.header(axum::http::header::COOKIE, &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res_system.status(), StatusCode::OK);
|
||||
|
||||
// 3. Perform Logout
|
||||
let res_logout = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/logout")
|
||||
.header(axum::http::header::COOKIE, &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res_logout.status(), StatusCode::OK);
|
||||
|
||||
let logout_cookie = res_logout
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
.expect("Set-Cookie on logout")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
logout_cookie.contains("Max-Age=0"),
|
||||
"Logout must clear session cookie with Max-Age=0"
|
||||
);
|
||||
|
||||
// 4. All protected endpoints must return 401 Unauthorized after logout
|
||||
let endpoints = [
|
||||
"/api/v1/auth/session",
|
||||
"/api/v1/system",
|
||||
"/api/v1/interfaces",
|
||||
"/api/v1/networks",
|
||||
"/api/v1/routes",
|
||||
"/api/v1/firewall/rules",
|
||||
"/api/v1/diagnostics/all",
|
||||
"/api/v1/client-profiles",
|
||||
"/api/v1/audit",
|
||||
"/api/v1/backups",
|
||||
"/api/v1/reconcile/plan",
|
||||
];
|
||||
|
||||
for ep in endpoints {
|
||||
let res_blocked = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(ep)
|
||||
.header(axum::http::header::COOKIE, &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
res_blocked.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Endpoint {ep} must be blocked (401) after logout"
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Repeated logout when already logged out is safe and idempotent
|
||||
let res_logout_again = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/logout")
|
||||
.header(axum::http::header::COOKIE, &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res_logout_again.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ui_index_contains_login_view_and_hidden_app_layout() {
|
||||
let (app, _store) = setup_test_app().await;
|
||||
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("index request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
|
||||
assert!(
|
||||
html.contains("id=\"login-view\""),
|
||||
"HTML must contain dedicated login-view container"
|
||||
);
|
||||
assert!(
|
||||
html.contains("id=\"app-layout\" style=\"display: none;\""),
|
||||
"app-layout must be initially hidden until authenticated"
|
||||
);
|
||||
assert!(
|
||||
html.contains("id=\"login-username\""),
|
||||
"HTML must contain login username input"
|
||||
);
|
||||
assert!(
|
||||
html.contains("id=\"login-password\""),
|
||||
"HTML must contain login password input"
|
||||
);
|
||||
assert!(
|
||||
html.contains("id=\"login-submit-btn\""),
|
||||
"HTML must contain login submit button"
|
||||
);
|
||||
assert!(
|
||||
html.contains("handleLogout()"),
|
||||
"HTML must contain handleLogout handler"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user