Release v1.1.0

This commit is contained in:
thakares committed 2026-09-02 15:19:19 +05:30
1 parent 34227efd2b
commit edc710cbd2
46 files changed
+5324 -190

No files matched your search

+6 -1
View File
@@ -325,7 +325,12 @@ impl DiagnosticsService {
stats.listen_port,
stats.peers.len()
),
expected_value: Some(format!("port {}", iface.listen_port)),
expected_value: Some(
iface
.listen_port
.map(|p| format!("port {p}"))
.unwrap_or_else(|| "port auto".to_string()),
),
diagnostic_message: format!(
"Interface '{}' is running and responsive",
iface.name
+65 -15
View File
@@ -5,7 +5,7 @@ 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_core::types::wireguard::{InterfaceRole, PeerState};
use nx9_wg_db::Store;
use nx9_wg_network::NetworkEngine;
use nx9_wireguard::WireGuardEngine;
@@ -28,16 +28,12 @@ fn matches_ipnet(live_addrs: &[String], desired: &IpNet) -> bool {
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())
.filter_map(|s| s.trim().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())
.filter_map(|s| s.trim().parse::<IpNet>().ok())
.collect();
desired_nets == live_nets
@@ -45,15 +41,13 @@ fn matches_allowed_ips(live_allowed_ips: &[String], desired_str: &str) -> bool {
/// Collect Interface CIDRs plus enabled Subnet Network CIDRs for NAT/forwarding.
///
/// Interface addresses remain the WireGuard transport identity. Enabled Network
/// CIDRs are the peer allocation domains and must be masqueraded so selected-
/// Network peers receive the same full-tunnel Internet path as Interface-CIDR
/// peers. `network_id = null` peers still match the Interface CIDR.
/// Only `InterfaceRole::Overlay` interfaces and peer-allocation Networks are collected
/// for client WAN NAT. Upstream interface addresses are not included.
pub async fn collect_managed_wg_subnets(store: &Store) -> ApiResult<Vec<IpNet>> {
let mut subnets = Vec::new();
for iface in store.list_interfaces().await? {
if !iface.enabled {
if !iface.enabled || iface.role != InterfaceRole::Overlay {
continue;
}
subnets.push(iface.address_v4);
@@ -205,8 +199,13 @@ impl ReconciliationEngine {
{
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 let Some(desired_port) = iface.listen_port {
if desired_port != 0
&& stats.listen_port != 0
&& stats.listen_port != desired_port
{
drift_reasons.push("listen port mismatch".to_string());
}
}
if !matches_ipnet(&stats.addresses, &iface.address_v4) {
drift_reasons
@@ -278,7 +277,8 @@ impl ReconciliationEngine {
for p in &active_desired_peers {
let pub_key_str = p.public_key.as_str();
let desired_server_allowed = p.server_wireguard_allowed_ips();
let desired_server_allowed =
p.server_wireguard_allowed_ips_for_role(iface.role);
if let Some(live_p) = live_peers_map.get(pub_key_str) {
// Peer is present in live kernel interface. Verify semantic drift:
@@ -384,6 +384,24 @@ impl ReconciliationEngine {
}
}
// Detect orphan kernel interfaces not in desired state
let desired_names: std::collections::HashSet<_> =
desired_interfaces.iter().map(|i| i.name.as_str()).collect();
for live_name in &live_interfaces {
if !desired_names.contains(live_name.as_str()) {
plan.actions.push(ReconciliationAction {
subsystem: "wireguard".to_string(),
resource_id: live_name.clone(),
action_type: "delete_orphan_interface".to_string(),
description: format!(
"Orphan WireGuard interface '{}' exists in kernel but not in desired state; remove",
live_name
),
});
plan.interface_changes += 1;
}
}
// 2. Routes (SQLite Routes table only; peer-allocation Networks are not routes)
let desired_routes = self.state.store.list_routes().await?;
let enabled_routes: Vec<_> = desired_routes.iter().filter(|r| r.enabled).collect();
@@ -502,6 +520,18 @@ impl ReconciliationEngine {
}
let desired_interfaces = self.state.store.list_interfaces().await?;
// Safety: refuse to orphan-cleanup if desired state appears empty
// while live kernel interfaces exist.
if desired_interfaces.is_empty() {
let live_check = self.wg_engine.list_interfaces().await.unwrap_or_default();
if !live_check.is_empty() {
return Err(ApiError::Internal(
"Reconciliation aborted: desired state is empty but live kernel interfaces \
exist. This may indicate a database read failure."
.to_string(),
));
}
}
let mut details = Vec::new();
// 1. Sync all active WireGuard interfaces and their peers
@@ -531,6 +561,26 @@ impl ReconciliationEngine {
}
}
// Remove orphan kernel WireGuard interfaces absent from desired state
let desired_names: std::collections::HashSet<_> =
desired_interfaces.iter().map(|i| i.name.as_str()).collect();
let live_interfaces = self.wg_engine.list_interfaces().await.unwrap_or_default();
for live_name in &live_interfaces {
if !desired_names.contains(live_name.as_str()) {
match self.wg_engine.delete_interface(live_name).await {
Ok(()) => {
details.push(format!("Removed orphan kernel interface '{}'", live_name));
}
Err(e) => {
details.push(format!(
"Failed to remove orphan kernel interface '{}': {e}",
live_name
));
}
}
}
}
let wg_subnets = collect_managed_wg_subnets(&self.state.store).await?;
// 2. Sync Routes (SQLite Routes table only; peer-allocation Networks are not routes)
+603 -34
View File
@@ -323,6 +323,9 @@
case 'live-state':
await renderLiveStatePage(container);
break;
case 'cli-console':
await renderCliConsolePage(container);
break;
case 'settings':
await renderSettingsPage(container);
break;
@@ -942,7 +945,7 @@
<div class="page-header">
<div class="page-title-group">
<h1>Interfaces</h1>
<div class="page-description">Authoritative Linux WireGuard server interfaces and netlink parameters.</div>
<div class="page-description">Authoritative Linux WireGuard server interfaces, roles (Overlay vs Upstream), and netlink parameters.</div>
</div>
<div class="page-actions" style="display: flex; gap: 8px;">
<button class="btn btn-secondary" onclick="renderPage('interfaces')">↻ Refresh</button>
@@ -954,6 +957,7 @@
<thead>
<tr>
<th>Status</th>
<th>Role</th>
<th>Interface</th>
<th>Listen Port</th>
<th>IPv4 Address</th>
@@ -963,20 +967,29 @@
</tr>
</thead>
<tbody>
${interfacesData.length === 0 ? `<tr><td colspan="7" style="text-align: center; color: var(--text-muted); padding: 24px;">No WireGuard interfaces configured. Click "+ Create Interface" to add one.</td></tr>` : interfacesData.map(i => `
${interfacesData.length === 0 ? `<tr><td colspan="8" style="text-align: center; color: var(--text-muted); padding: 24px;">No WireGuard interfaces configured. Click "+ Create Interface" to add one.</td></tr>` : interfacesData.map(i => `
<tr>
<td><span class="status-pill ${i.enabled ? 'status-pass' : 'status-warning'}">${i.enabled ? 'Enabled' : 'Disabled'}</span></td>
<td><span class="status-pill ${(i.role || 'overlay') === 'upstream' ? 'status-info' : 'status-pass'}">${(i.role || 'overlay') === 'upstream' ? 'Upstream' : 'Overlay'}</span></td>
<td><strong>${escapeHtml(i.name)}</strong></td>
<td>${i.listen_port}</td>
<td>${i.listen_port ? i.listen_port : '<span style="color: var(--text-muted);">Auto</span>'}</td>
<td><span class="key-code">${i.address_v4}</span></td>
<td>${i.mtu || 1420}</td>
<td><span class="key-code" title="${escapeHtml(i.public_key || '')}">${i.public_key ? i.public_key.substring(0,10) + '...' : 'Generated on apply'}</span></td>
<td>
<div style="display: flex; gap: 6px;">
<button class="btn btn-secondary btn-sm" onclick="openEditInterfaceModal('${i.id}')">Edit</button>
<button class="btn btn-secondary btn-sm" onclick="toggleInterfaceState('${i.id}', ${i.enabled})">${i.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-danger btn-sm" onclick="deleteInterface('${i.id}')">Delete</button>
</div>
${i.name === 'wg0' ? `
<div style="display: flex; gap: 6px;">
<button class="btn btn-secondary btn-sm" onclick="openEditInterfaceModal('${i.id}')">Edit</button>
<button class="btn btn-secondary btn-sm" onclick="restartInterface('${i.id}', '${escapeHtml(i.name)}')">Restart</button>
</div>
` : `
<div style="display: flex; gap: 6px;">
<button class="btn btn-secondary btn-sm" onclick="openEditInterfaceModal('${i.id}')">Edit</button>
<button class="btn btn-secondary btn-sm" onclick="restartInterface('${i.id}', '${escapeHtml(i.name)}')">Restart</button>
<button class="btn btn-secondary btn-sm" onclick="toggleInterfaceState('${i.id}', ${i.enabled})">${i.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-danger btn-sm" onclick="deleteInterface('${i.id}')">Delete</button>
</div>
`}
</td>
</tr>
`).join('')}
@@ -989,47 +1002,195 @@
window.openCreateInterfaceModal = function() {
openModal(`
<div class="modal-backdrop" onclick="if(event.target === this) closeModal()">
<div class="modal-sheet">
<div class="modal-sheet" style="max-width: 600px;">
<div class="modal-header">
<div class="modal-title">Create WireGuard Interface</div>
<button class="modal-close-btn" onclick="closeModal()">✕</button>
</div>
<div class="modal-body">
<div id="iface-modal-error" style="display: none; margin-bottom: 12px;" class="alert-box danger"></div>
<div class="form-group">
<label class="form-label">Interface Name *</label>
<input type="text" id="iface-name" class="form-input" placeholder="e.g. wg0" required>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">IPv4 Subnet Address *</label>
<input type="text" id="iface-v4" class="form-input" placeholder="e.g. 10.100.0.1/24" required>
</div>
<div class="form-group">
<label class="form-label">Listen Port *</label>
<input type="number" id="iface-port" class="form-input" value="51820" required>
<div class="form-group" style="margin-bottom: 16px;">
<label class="form-label">Interface Role *</label>
<div style="display: flex; gap: 12px; margin-top: 6px;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="radio" name="iface-role" value="overlay" onchange="switchInterfaceRole('overlay')" checked>
<span><strong>Overlay</strong> (Primary Client Network)</span>
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="radio" name="iface-role" value="upstream" onchange="switchInterfaceRole('upstream')">
<span><strong>Upstream</strong> (Third-Party VPN / Tunnel)</span>
</label>
</div>
</div>
<div class="form-grid-2">
<!-- Overlay Mode Form -->
<div id="iface-overlay-fields">
<div class="form-group">
<label class="form-label">MTU</label>
<input type="number" id="iface-mtu" class="form-input" value="1420">
<label class="form-label">Interface Name *</label>
<input type="text" id="iface-name" class="form-input" placeholder="e.g. wg0" value="wg0" required>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">IPv4 Subnet Address *</label>
<input type="text" id="iface-v4" class="form-input" placeholder="e.g. 10.100.0.1/24" required>
</div>
<div class="form-group">
<label class="form-label">Listen Port *</label>
<input type="number" id="iface-port" class="form-input" value="51820" required>
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">MTU</label>
<input type="number" id="iface-mtu" class="form-input" value="1420">
</div>
<div class="form-group">
<label class="form-label">IPv6 Subnet (Optional)</label>
<input type="text" id="iface-v6" class="form-input" placeholder="e.g. fd00::1/64">
</div>
</div>
</div>
<!-- Upstream Mode Form -->
<div id="iface-upstream-fields" style="display: none;">
<div class="form-group">
<label class="form-label">Upstream Interface Name *</label>
<input type="text" id="upstream-name" class="form-input" placeholder="e.g. proton0" value="proton0">
</div>
<div class="form-group">
<label class="form-label">IPv6 Subnet (Optional)</label>
<input type="text" id="iface-v6" class="form-input" placeholder="e.g. fd00::1/64">
<label class="form-label">WireGuard Configuration (.conf) *</label>
<textarea id="upstream-config" class="form-input font-mono" rows="8" placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = 10.2.0.2/32&#10;DNS = 10.2.0.1&#10;&#10;[Peer]&#10;PublicKey = ...&#10;Endpoint = 37.19.199.155:51820&#10;AllowedIPs = 0.0.0.0/0, ::/0&#10;PersistentKeepalive = 25"></textarea>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Paste standard third-party configuration (e.g. ProtonVPN). Must contain [Interface] and exactly one [Peer].</div>
</div>
<div style="display: flex; justify-content: flex-end; margin-bottom: 12px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="previewUpstreamConfig()">🔍 Parse & Validate</button>
</div>
<div id="upstream-preview-box" style="display: none; background: var(--bg-surface); border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; margin-top: 8px;">
<div style="font-weight: bold; margin-bottom: 8px; font-size: 13px;">Validated Configuration Preview</div>
<div id="upstream-preview-content" style="font-size: 12px; font-family: monospace;"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitCreateInterface()">Create Interface</button>
<button id="iface-submit-btn" class="btn btn-primary" onclick="submitCreateInterface()">Create Interface</button>
</div>
</div>
</div>
`);
};
window.switchInterfaceRole = function(role) {
const overlayFields = document.getElementById('iface-overlay-fields');
const upstreamFields = document.getElementById('iface-upstream-fields');
const submitBtn = document.getElementById('iface-submit-btn');
const errBox = document.getElementById('iface-modal-error');
if (errBox) errBox.style.display = 'none';
if (role === 'upstream') {
if (overlayFields) overlayFields.style.display = 'none';
if (upstreamFields) upstreamFields.style.display = 'block';
if (submitBtn) {
submitBtn.textContent = 'Confirm & Import Upstream';
submitBtn.onclick = submitImportUpstream;
}
} else {
if (overlayFields) overlayFields.style.display = 'block';
if (upstreamFields) upstreamFields.style.display = 'none';
if (submitBtn) {
submitBtn.textContent = 'Create Interface';
submitBtn.onclick = submitCreateInterface;
}
}
};
window.previewUpstreamConfig = async function() {
const errBox = document.getElementById('iface-modal-error');
const previewBox = document.getElementById('upstream-preview-box');
const previewContent = document.getElementById('upstream-preview-content');
if (errBox) errBox.style.display = 'none';
const name = document.getElementById('upstream-name')?.value?.trim();
const config = document.getElementById('upstream-config')?.value?.trim();
if (!name || !config) {
if (errBox) {
errBox.style.display = 'block';
errBox.textContent = '❌ Please provide Interface Name and WireGuard .conf content.';
}
return;
}
const res = await api('/interfaces/upstreams/preview', {
method: 'POST',
body: JSON.stringify({ name, config })
});
if (res && !res.error) {
if (previewBox && previewContent) {
previewBox.style.display = 'block';
previewContent.innerHTML = `
<div><strong>Name:</strong> ${escapeHtml(res.name)}</div>
<div><strong>Role:</strong> <span class="status-pill status-info">${escapeHtml(res.role)}</span></div>
<div><strong>Listen Port:</strong> ${res.listen_port ? res.listen_port : '<span class="status-pill status-secondary">Auto (Dynamic)</span>'}</div>
<div><strong>Tunnel Address:</strong> ${escapeHtml(res.address_v4)}${res.address_v6 ? ', ' + escapeHtml(res.address_v6) : ''}</div>
<div><strong>DNS:</strong> ${escapeHtml(res.dns || 'None')}</div>
<div><strong>MTU:</strong> ${res.mtu || 1420}</div>
<div style="margin-top: 6px; border-top: 1px dashed var(--border-color); padding-top: 6px;">
<strong>Provider Peer:</strong>
<div style="margin-left: 8px;">
<div>• Public Key: <span class="key-code">${escapeHtml(res.provider_public_key)}</span></div>
<div>• Endpoint: ${escapeHtml(res.provider_endpoint)}</div>
<div>• AllowedIPs: <span class="key-code">${escapeHtml(res.provider_allowed_ips)}</span></div>
<div>• Keepalive: ${res.persistent_keepalive ? res.persistent_keepalive + 's' : 'None'}</div>
<div>• PresharedKey: ${res.preshared_key_configured ? 'Configured' : 'None'}</div>
</div>
</div>
`;
}
} else {
const errMsg = extractErrorMessage(res);
if (previewBox) previewBox.style.display = 'none';
if (errBox) {
errBox.style.display = 'block';
errBox.textContent = '❌ Configuration Validation Error: ' + errMsg;
}
}
};
window.submitImportUpstream = async function() {
const errBox = document.getElementById('iface-modal-error');
if (errBox) errBox.style.display = 'none';
const name = document.getElementById('upstream-name')?.value?.trim();
const config = document.getElementById('upstream-config')?.value?.trim();
if (!name || !config) {
if (errBox) {
errBox.style.display = 'block';
errBox.textContent = '❌ Please provide Interface Name and WireGuard .conf content.';
}
return;
}
const res = await api('/interfaces/upstreams/import', {
method: 'POST',
body: JSON.stringify({ name, config })
});
if (res && !res.error) {
closeModal();
renderPage('interfaces');
} else {
const errMsg = extractErrorMessage(res);
if (errBox) {
errBox.style.display = 'block';
errBox.textContent = '❌ Failed to import Upstream: ' + errMsg;
}
}
};
window.submitCreateInterface = async function() {
const errBox = document.getElementById('iface-modal-error');
if (errBox) errBox.style.display = 'none';
@@ -1120,21 +1281,21 @@
</div>
<div class="form-group">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="edit-iface-enabled" ${iface.enabled ? 'checked' : ''}>
<span>Interface Enabled</span>
<input type="checkbox" id="edit-iface-enabled" ${iface.enabled ? 'checked' : ''} ${iface.name === 'wg0' ? 'disabled' : ''}>
<span>Interface Enabled ${iface.name === 'wg0' ? '(Primary overlay cannot be disabled)' : ''}</span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitEditInterface('${iface.id}')">Save Changes</button>
<button class="btn btn-primary" onclick="submitEditInterface('${iface.id}', '${escapeHtml(iface.name)}')">Save Changes</button>
</div>
</div>
</div>
`);
};
window.submitEditInterface = async function(ifaceId) {
window.submitEditInterface = async function(ifaceId, origName) {
const errBox = document.getElementById('edit-iface-modal-error');
if (errBox) errBox.style.display = 'none';
@@ -1144,7 +1305,7 @@
const mtu = parseInt(document.getElementById('edit-iface-mtu')?.value || '1420', 10);
const address_v6 = document.getElementById('edit-iface-v6')?.value?.trim() || '';
const dns = document.getElementById('edit-iface-dns')?.value?.trim() || '';
const enabled = document.getElementById('edit-iface-enabled')?.checked ?? true;
const enabled = (origName === 'wg0' || name === 'wg0') ? true : (document.getElementById('edit-iface-enabled')?.checked ?? true);
if (!name || !address_v4) {
if (errBox) {
@@ -1181,15 +1342,32 @@
}
};
window.restartInterface = async function(id, name) {
if (confirm(`Are you sure you want to restart interface '${name}'? This will tear down the kernel device and restore all desired configuration and peers.`)) {
const res = await api(`/interfaces/${id}/restart`, { method: 'POST' });
if (res && !res.error) {
renderPage('interfaces');
} else {
alert('Failed to restart interface: ' + extractErrorMessage(res));
}
}
};
window.toggleInterfaceState = async function(id, currentState) {
const action = currentState ? 'disable' : 'enable';
await api(`/interfaces/${id}/${action}`, { method: 'POST' });
const res = await api(`/interfaces/${id}/${action}`, { method: 'POST' });
if (res && res.error) {
alert('Failed to update interface state: ' + extractErrorMessage(res));
}
renderPage('interfaces');
};
window.deleteInterface = async function(id) {
if (confirm('Are you sure you want to delete this interface? All associated peers will be removed.')) {
await api(`/interfaces/${id}`, { method: 'DELETE' });
const res = await api(`/interfaces/${id}`, { method: 'DELETE' });
if (res && res.error) {
alert('Failed to delete interface: ' + extractErrorMessage(res));
}
renderPage('interfaces');
}
};
@@ -1966,6 +2144,397 @@
`;
}
// ── CLI Console (Read-Only) ──────────────────────────────────────────────────
let cliCommandsData = [];
let cliSelectedCmd = null;
let cliSelectedSubcmd = null;
let cliSelectedSubSubcmd = null;
async function renderCliConsolePage(container) {
const res = await api('/system/cli/commands');
cliCommandsData = (res && Array.isArray(res.commands)) ? res.commands : [];
cliSelectedCmd = null;
cliSelectedSubcmd = null;
cliSelectedSubSubcmd = null;
container.innerHTML = `
<div class="page-header">
<div class="page-title-group">
<h1>CLI Console</h1>
<div class="page-description">Interactive read-only appliance CLI query console (nx9-wg).</div>
</div>
<div class="page-actions">
<span class="status-pill status-pass">Read-Only Enforced</span>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<div class="card-header-bar">
<div class="card-header-title">Command Selector</div>
</div>
<form id="cli-console-form" onsubmit="event.preventDefault(); executeCliConsoleCommand();">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 12px;">
<div class="form-group">
<label class="form-label" for="cli-cmd-select">1. Command *</label>
<select id="cli-cmd-select" class="form-input" onchange="onCliCommandChange(this.value)">
<option value="">-- Select Command --</option>
${cliCommandsData.map(c => `<option value="${escapeHtml(c.name)}">${escapeHtml(c.name)} — ${escapeHtml(c.description)}</option>`).join('')}
</select>
</div>
<div class="form-group" id="cli-subcmd-group" style="display: none;">
<label class="form-label" for="cli-subcmd-select">2. Sub-command *</label>
<select id="cli-subcmd-select" class="form-input" onchange="onCliSubcommandChange(this.value)">
<option value="">-- Select Sub-command --</option>
</select>
</div>
<div class="form-group" id="cli-sub-subcmd-group" style="display: none;">
<label class="form-label" for="cli-sub-subcmd-select">3. Sub-sub-command *</label>
<select id="cli-sub-subcmd-select" class="form-input" onchange="onCliSubSubcommandChange(this.value)">
<option value="">-- Select Option --</option>
</select>
</div>
<div class="form-group" id="cli-target-group" style="display: none;">
<label class="form-label" id="cli-target-label" for="cli-target-input">Target *</label>
<input type="text" id="cli-target-input" class="form-input" placeholder="Enter target..." oninput="updateCliCommandPreview()">
</div>
</div>
<div id="cli-params-container" style="display: none; margin-top: 12px; padding: 12px; background: var(--bg-surface-raised, #181c24); border-radius: var(--radius-md); border: 1px solid var(--border-subtle, rgba(255,255,255,0.06));">
<div style="font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px;">Parameters & Options</div>
<div id="cli-params-fields" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;"></div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border-muted, rgba(255,255,255,0.08));">
<div style="font-family: monospace; font-size: 13px; color: var(--text-secondary);" id="cli-constructed-cmd">
nx9-wg
</div>
<button type="submit" id="cli-exec-btn" class="btn btn-primary" disabled>
▶ Execute Command
</button>
</div>
</form>
</div>
<div class="card">
<div class="card-header-bar">
<div class="card-header-title">Response Window</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span id="cli-status-pill" class="status-pill" style="display: none;"></span>
<button class="btn btn-secondary btn-sm" onclick="copyCliOutput()" id="cli-copy-btn" disabled>📋 Copy Output</button>
<button class="btn btn-secondary btn-sm" onclick="clearCliOutput()">Clear</button>
</div>
</div>
<div id="cli-response-wrapper" style="margin-top: 12px;">
<pre id="cli-response-pre" style="background: var(--bg-surface-raised, #12151c); color: var(--text-primary); padding: 16px; border-radius: var(--radius-md); font-family: monospace; font-size: 12px; line-height: 1.5; min-height: 140px; max-height: 480px; overflow-y: auto; border: 1px solid var(--border-subtle, rgba(255,255,255,0.08)); margin: 0; white-space: pre-wrap; word-break: break-all;">Select a command above and click "Execute Command" to view output.</pre>
</div>
</div>
`;
}
window.onCliCommandChange = function(cmdName) {
const subGroup = document.getElementById('cli-subcmd-group');
const subSelect = document.getElementById('cli-subcmd-select');
const subSubGroup = document.getElementById('cli-sub-subcmd-group');
const targetGroup = document.getElementById('cli-target-group');
const paramsContainer = document.getElementById('cli-params-container');
cliSelectedCmd = cliCommandsData.find(c => c.name === cmdName) || null;
cliSelectedSubcmd = null;
cliSelectedSubSubcmd = null;
if (subSubGroup) subSubGroup.style.display = 'none';
if (targetGroup) targetGroup.style.display = 'none';
if (paramsContainer) paramsContainer.style.display = 'none';
if (!cliSelectedCmd) {
if (subGroup) subGroup.style.display = 'none';
updateCliCommandPreview();
return;
}
if (cliSelectedCmd.subcommands && cliSelectedCmd.subcommands.length > 0) {
if (subGroup && subSelect) {
subGroup.style.display = 'block';
subSelect.innerHTML = `<option value="">-- Select Sub-command --</option>` +
cliSelectedCmd.subcommands.map(s => `<option value="${escapeHtml(s.name)}">${escapeHtml(s.name)} — ${escapeHtml(s.description)}</option>`).join('');
}
} else {
if (subGroup) subGroup.style.display = 'none';
renderCliActiveTargetAndParams(cliSelectedCmd);
}
updateCliCommandPreview();
};
window.onCliSubcommandChange = function(subName) {
const subSubGroup = document.getElementById('cli-sub-subcmd-group');
const subSubSelect = document.getElementById('cli-sub-subcmd-select');
const targetGroup = document.getElementById('cli-target-group');
const paramsContainer = document.getElementById('cli-params-container');
if (!cliSelectedCmd || !cliSelectedCmd.subcommands) return;
cliSelectedSubcmd = cliSelectedCmd.subcommands.find(s => s.name === subName) || null;
cliSelectedSubSubcmd = null;
if (targetGroup) targetGroup.style.display = 'none';
if (paramsContainer) paramsContainer.style.display = 'none';
if (!cliSelectedSubcmd) {
if (subSubGroup) subSubGroup.style.display = 'none';
updateCliCommandPreview();
return;
}
if (cliSelectedSubcmd.sub_subcommands && cliSelectedSubcmd.sub_subcommands.length > 0) {
if (subSubGroup && subSubSelect) {
subSubGroup.style.display = 'block';
subSubSelect.innerHTML = `<option value="">-- Select Option --</option>` +
cliSelectedSubcmd.sub_subcommands.map(s => `<option value="${escapeHtml(s.name)}">${escapeHtml(s.name)} — ${escapeHtml(s.description)}</option>`).join('');
}
} else {
if (subSubGroup) subSubGroup.style.display = 'none';
renderCliActiveTargetAndParams(cliSelectedSubcmd);
}
updateCliCommandPreview();
};
window.onCliSubSubcommandChange = function(subSubName) {
if (!cliSelectedSubcmd || !cliSelectedSubcmd.sub_subcommands) return;
cliSelectedSubSubcmd = cliSelectedSubcmd.sub_subcommands.find(s => s.name === subSubName) || null;
if (cliSelectedSubSubcmd) {
renderCliActiveTargetAndParams(cliSelectedSubSubcmd);
} else {
const targetGroup = document.getElementById('cli-target-group');
const paramsContainer = document.getElementById('cli-params-container');
if (targetGroup) targetGroup.style.display = 'none';
if (paramsContainer) paramsContainer.style.display = 'none';
}
updateCliCommandPreview();
};
function renderCliActiveTargetAndParams(meta) {
const targetGroup = document.getElementById('cli-target-group');
const targetLabel = document.getElementById('cli-target-label');
const targetInput = document.getElementById('cli-target-input');
const paramsContainer = document.getElementById('cli-params-container');
const paramsFields = document.getElementById('cli-params-fields');
if (meta.target_label) {
if (targetGroup && targetLabel && targetInput) {
targetGroup.style.display = 'block';
targetLabel.textContent = meta.target_label + (meta.target_required ? ' *' : '');
targetInput.placeholder = meta.target_label;
targetInput.value = '';
}
} else {
if (targetGroup) targetGroup.style.display = 'none';
if (targetInput) targetInput.value = '';
}
if (meta.parameters && meta.parameters.length > 0) {
if (paramsContainer && paramsFields) {
paramsContainer.style.display = 'block';
paramsFields.innerHTML = meta.parameters.map(p => `
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label" style="font-size: 11px;">${escapeHtml(p.name)} (${escapeHtml(p.flag)})${p.required ? ' *' : ''}</label>
<input type="text" id="cli-param-${p.name}" class="form-input" style="padding: 6px 10px; font-size: 12px;" placeholder="${escapeHtml(p.description)}" value="${escapeHtml(p.default_value || '')}" oninput="updateCliCommandPreview()">
</div>
`).join('');
}
} else {
if (paramsContainer) paramsContainer.style.display = 'none';
if (paramsFields) paramsFields.innerHTML = '';
}
}
function updateCliCommandPreview() {
const preview = document.getElementById('cli-constructed-cmd');
const execBtn = document.getElementById('cli-exec-btn');
if (!preview || !execBtn) return;
if (!cliSelectedCmd) {
preview.textContent = 'nx9-wg';
execBtn.disabled = true;
return;
}
const parts = ['nx9-wg', cliSelectedCmd.name];
let canExecute = true;
if (cliSelectedCmd.subcommands && cliSelectedCmd.subcommands.length > 0) {
if (!cliSelectedSubcmd) {
canExecute = false;
} else {
parts.push(cliSelectedSubcmd.name);
if (cliSelectedSubcmd.sub_subcommands && cliSelectedSubcmd.sub_subcommands.length > 0) {
if (!cliSelectedSubSubcmd) {
canExecute = false;
} else {
parts.push(cliSelectedSubSubcmd.name);
}
}
}
}
const activeMeta = cliSelectedSubSubcmd || cliSelectedSubcmd || cliSelectedCmd;
if (activeMeta && activeMeta.target_label) {
const targetVal = document.getElementById('cli-target-input')?.value?.trim();
if (targetVal) {
parts.push(targetVal);
} else if (activeMeta.target_required) {
parts.push(`<${activeMeta.target_label}>`);
canExecute = false;
}
}
if (activeMeta && activeMeta.parameters) {
for (const p of activeMeta.parameters) {
const val = document.getElementById(`cli-param-${p.name}`)?.value?.trim();
if (val) {
parts.push(p.flag, val);
} else if (p.required) {
parts.push(p.flag, `<${p.name}>`);
canExecute = false;
}
}
}
preview.textContent = parts.join(' ');
execBtn.disabled = !canExecute;
}
window.executeCliConsoleCommand = async function() {
const execBtn = document.getElementById('cli-exec-btn');
const outputPre = document.getElementById('cli-response-pre');
const statusPill = document.getElementById('cli-status-pill');
const copyBtn = document.getElementById('cli-copy-btn');
if (!cliSelectedCmd) return;
const activeMeta = cliSelectedSubSubcmd || cliSelectedSubcmd || cliSelectedCmd;
const targetVal = document.getElementById('cli-target-input')?.value?.trim() || null;
if (activeMeta && activeMeta.target_required && !targetVal) {
alert(`Please provide ${activeMeta.target_label}`);
return;
}
const params = {};
if (activeMeta && activeMeta.parameters) {
for (const p of activeMeta.parameters) {
const val = document.getElementById(`cli-param-${p.name}`)?.value?.trim();
if (val) {
params[p.name] = val;
} else if (p.required) {
alert(`Please provide ${p.name}`);
return;
}
}
}
if (execBtn) {
execBtn.disabled = true;
execBtn.textContent = '⏳ Executing...';
}
if (outputPre) {
outputPre.textContent = 'Executing command...';
outputPre.style.color = 'var(--text-secondary)';
}
if (statusPill) statusPill.style.display = 'none';
const payload = {
command: cliSelectedCmd.name,
subcommand: cliSelectedSubcmd?.name || null,
sub_subcommand: cliSelectedSubSubcmd?.name || null,
target: targetVal,
parameters: params
};
const res = await api('/system/cli', {
method: 'POST',
body: JSON.stringify(payload)
});
if (execBtn) {
execBtn.disabled = false;
execBtn.textContent = '▶ Execute Command';
}
if (res && typeof res.exit_code === 'number') {
let displayText = '';
if (res.stdout) {
displayText += res.stdout;
}
if (res.stderr) {
if (displayText.length > 0) displayText += '\n--- STDERR ---\n';
displayText += res.stderr;
}
if (!displayText) {
displayText = `(Process exited with code ${res.exit_code} and produced no output)`;
}
if (outputPre) {
outputPre.textContent = displayText;
outputPre.style.color = res.success ? 'var(--text-primary)' : 'var(--status-fail-text, #ff6b6b)';
}
if (statusPill) {
statusPill.style.display = 'inline-block';
statusPill.className = `status-pill ${res.success ? 'status-pass' : 'status-fail'}`;
statusPill.textContent = `Exit Code ${res.exit_code}`;
}
if (copyBtn) copyBtn.disabled = false;
} else {
const errMsg = extractErrorMessage(res);
if (outputPre) {
outputPre.textContent = `❌ Execution Error: ${errMsg}`;
outputPre.style.color = 'var(--status-fail-text, #ff6b6b)';
}
if (statusPill) {
statusPill.style.display = 'inline-block';
statusPill.className = 'status-pill status-fail';
statusPill.textContent = 'Failed';
}
if (copyBtn) copyBtn.disabled = false;
}
};
window.copyCliOutput = function() {
const text = document.getElementById('cli-response-pre')?.textContent;
if (text) {
navigator.clipboard.writeText(text).then(() => {
const copyBtn = document.getElementById('cli-copy-btn');
if (copyBtn) {
const original = copyBtn.textContent;
copyBtn.textContent = '✓ Copied!';
setTimeout(() => { copyBtn.textContent = original; }, 2000);
}
}).catch(err => {
alert('Failed to copy: ' + err);
});
}
};
window.clearCliOutput = function() {
const outputPre = document.getElementById('cli-response-pre');
const statusPill = document.getElementById('cli-status-pill');
const copyBtn = document.getElementById('cli-copy-btn');
if (outputPre) {
outputPre.textContent = 'Select a command above and click "Execute Command" to view output.';
outputPre.style.color = 'var(--text-secondary)';
}
if (statusPill) statusPill.style.display = 'none';
if (copyBtn) copyBtn.disabled = true;
};
// ── Settings Management ─────────────────────────────────────────────────────
async function renderSettingsPage(container) {
const settings = await api('/system/settings') || [];
@@ -109,6 +109,9 @@
<a href="#live-state" class="nav-link" onclick="navigateTo('live-state')">
<span class="nav-icon">📡</span> Live State
</a>
<a href="#cli-console" class="nav-link" onclick="navigateTo('cli-console')">
<span class="nav-icon">💻</span> CLI Console
</a>
</div>
<!-- Administration Navigation -->
File diff suppressed because it is too large. Load diff
+244 -6
View File
@@ -1,5 +1,3 @@
//! WireGuard Interface HTTP handlers.
use crate::error::{ApiError, ApiResult};
use crate::routes::auth::GenericSuccess;
use crate::state::{AppState, SystemEvent};
@@ -7,16 +5,20 @@ 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::types::wireguard::{
Interface, InterfaceRole, WireGuardPrivateKey, WireGuardPublicKey,
};
use nx9_wg_core::validation::{
validate_cidr, validate_interface_name, validate_listen_port, validate_mtu,
};
use nx9_wireguard::UpstreamConfigParser;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateInterfaceRequest {
pub name: String,
pub role: Option<InterfaceRole>,
pub listen_port: Option<u16>,
pub address_v4: String,
pub address_v6: Option<String>,
@@ -30,6 +32,54 @@ pub struct CreateInterfaceRequest {
pub post_down: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpstreamPreviewRequest {
pub name: String,
pub config: String,
}
#[derive(Debug, Serialize)]
pub struct UpstreamPreviewResponse {
pub name: String,
pub role: String,
pub address_v4: String,
pub address_v6: Option<String>,
pub dns: Option<String>,
pub mtu: Option<u16>,
pub listen_port: Option<u16>,
pub peer_count: usize,
pub provider_public_key: String,
pub provider_endpoint: String,
pub provider_allowed_ips: String,
pub persistent_keepalive: Option<u16>,
pub preshared_key_configured: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpstreamImportRequest {
pub name: String,
pub config: String,
}
#[derive(Debug, Serialize)]
pub struct UpstreamImportResponse {
pub interface_id: Uuid,
pub peer_id: Uuid,
pub name: String,
pub role: String,
pub address_v4: String,
pub address_v6: Option<String>,
pub dns: Option<String>,
pub mtu: Option<u16>,
pub listen_port: Option<u16>,
pub provider_public_key: String,
pub provider_endpoint: String,
pub provider_allowed_ips: String,
pub persistent_keepalive: Option<u16>,
pub preshared_key_configured: bool,
pub enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpdateInterfaceRequest {
pub name: Option<String>,
@@ -66,6 +116,30 @@ pub async fn create_interface_handler(
Json(payload): Json<CreateInterfaceRequest>,
) -> ApiResult<Json<Interface>> {
validate_interface_name(&payload.name)?;
let role = payload.role.unwrap_or(if payload.name == "wg0" {
InterfaceRole::Overlay
} else {
InterfaceRole::Upstream
});
if role == InterfaceRole::Overlay {
let existing = state.store.list_interfaces().await?;
if existing.iter().any(|i| i.role == InterfaceRole::Overlay) {
return Err(ApiError::Conflict(
"Only one Overlay interface ('wg0') is permitted".to_string(),
));
}
if payload.name != "wg0" {
return Err(ApiError::Validation(
"The primary overlay interface must be named 'wg0'".to_string(),
));
}
} else if payload.name == "wg0" {
return Err(ApiError::Validation(
"An Upstream interface cannot use the reserved name 'wg0'".to_string(),
));
}
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)?),
@@ -73,8 +147,14 @@ pub async fn create_interface_handler(
};
let listen_port = match payload.listen_port {
Some(p) => validate_listen_port(p)?,
None => 51820,
Some(p) => Some(validate_listen_port(p)?),
None => {
if role == InterfaceRole::Overlay {
Some(51820)
} else {
None
}
}
};
if let Some(m) = payload.mtu {
@@ -93,6 +173,7 @@ pub async fn create_interface_handler(
let iface = Interface {
id: Uuid::new_v4(),
name: payload.name,
role,
private_key: priv_k,
public_key: pub_k,
listen_port,
@@ -119,6 +200,100 @@ pub async fn create_interface_handler(
Ok(Json(iface))
}
/// POST /api/v1/interfaces/upstreams/preview
pub async fn preview_upstream_handler(
Json(payload): Json<UpstreamPreviewRequest>,
) -> ApiResult<Json<UpstreamPreviewResponse>> {
let parsed = UpstreamConfigParser::parse(&payload.config, &payload.name)
.map_err(|e| ApiError::Validation(e.to_string()))?;
Ok(Json(UpstreamPreviewResponse {
name: parsed.interface_name,
role: "upstream".to_string(),
address_v4: parsed.address_v4.to_string(),
address_v6: parsed.address_v6.map(|ip| ip.to_string()),
dns: parsed.dns,
mtu: parsed.mtu,
listen_port: parsed.listen_port,
peer_count: 1,
provider_public_key: parsed.peer.public_key.as_str().to_string(),
provider_endpoint: parsed.peer.endpoint,
provider_allowed_ips: parsed.peer.allowed_ips,
persistent_keepalive: parsed.peer.persistent_keepalive,
preshared_key_configured: parsed.peer.preshared_key.is_some(),
}))
}
/// POST /api/v1/interfaces/upstreams/import
pub async fn import_upstream_handler(
State(state): State<AppState>,
Json(payload): Json<UpstreamImportRequest>,
) -> ApiResult<Json<UpstreamImportResponse>> {
let parsed = UpstreamConfigParser::parse(&payload.config, &payload.name)
.map_err(|e| ApiError::Validation(e.to_string()))?;
// Check for interface name collision
if state
.store
.get_interface_by_name(&parsed.interface_name)
.await?
.is_some()
{
return Err(ApiError::Conflict(format!(
"An interface named '{}' already exists",
parsed.interface_name
)));
}
let interface_id = Uuid::new_v4();
let peer_id = Uuid::new_v4();
let psk_configured = parsed.peer.preshared_key.is_some();
let (iface, peer) = parsed.into_desired_state(interface_id, peer_id);
// Persist desired state transactionally
state.store.create_interface(&iface).await?;
if let Err(e) = state.store.create_peer(&peer).await {
let _ = state.store.delete_interface(iface.id).await;
return Err(ApiError::from(e));
}
// Synchronize to kernel / runtime state
if let Err(e) = state
.wg_engine
.sync_interface(&iface, &[peer.clone()])
.await
{
tracing::error!(
interface = %iface.name,
error = %e,
"Kernel sync failed after upstream import"
);
}
state.broadcast(SystemEvent::InterfaceChanged {
id: iface.id.to_string(),
action: "imported".to_string(),
});
Ok(Json(UpstreamImportResponse {
interface_id: iface.id,
peer_id: peer.id,
name: iface.name,
role: iface.role.to_string(),
address_v4: iface.address_v4.to_string(),
address_v6: iface.address_v6.map(|ip| ip.to_string()),
dns: iface.dns,
mtu: iface.mtu,
listen_port: iface.listen_port,
provider_public_key: peer.public_key.as_str().to_string(),
provider_endpoint: peer.endpoint.unwrap_or_default(),
provider_allowed_ips: peer.allowed_ips,
persistent_keepalive: peer.persistent_keepalive,
preshared_key_configured: psk_configured,
enabled: iface.enabled,
}))
}
/// GET /api/v1/interfaces/{id}
pub async fn get_interface_handler(
State(state): State<AppState>,
@@ -160,7 +335,7 @@ pub async fn update_interface_handler(
}
if let Some(port) = payload.listen_port {
validate_listen_port(port)?;
iface.listen_port = port;
iface.listen_port = Some(port);
}
if let Some(ref v4) = payload.address_v4 {
iface.address_v4 = validate_cidr(v4)?;
@@ -216,6 +391,22 @@ pub async fn delete_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
let iface = state
.store
.get_interface(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
if iface.name == "wg0" {
return Err(ApiError::Forbidden(
"The primary overlay interface 'wg0' cannot be deleted".to_string(),
));
}
// 1. Attempt kernel deletion
let _ = state.wg_engine.delete_interface(&iface.name).await;
// 2. Delete from DB
state.store.delete_interface(id).await?;
state.broadcast(SystemEvent::InterfaceChanged {
@@ -252,6 +443,18 @@ pub async fn disable_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
let iface = state
.store
.get_interface(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
if iface.name == "wg0" {
return Err(ApiError::Forbidden(
"The primary overlay interface 'wg0' cannot be disabled".to_string(),
));
}
state.store.set_interface_enabled(id, false).await?;
state.broadcast(SystemEvent::InterfaceChanged {
@@ -288,3 +491,38 @@ pub async fn interface_status_handler(
active_peer_count: active_count,
}))
}
/// POST /api/v1/interfaces/{id}/restart
pub async fn restart_interface_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<GenericSuccess>> {
let iface = state
.store
.get_interface(id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
// 1. Tear down the kernel WireGuard interface
let _ = state.wg_engine.delete_interface(&iface.name).await;
// 2. Re-sync from desired state (recreate link, addresses, peers, routes)
let peers = state.store.list_peers_for_interface(iface.id).await?;
state
.wg_engine
.sync_interface(&iface, &peers)
.await
.map_err(|e| {
ApiError::Internal(format!("Failed to restart interface '{}': {e}", iface.name))
})?;
state.broadcast(SystemEvent::InterfaceChanged {
id: iface.id.to_string(),
action: "restarted".to_string(),
});
Ok(Json(GenericSuccess {
success: true,
message: format!("Interface '{}' restarted successfully", iface.name),
}))
}
+15
View File
@@ -3,6 +3,7 @@
pub mod audit;
pub mod auth;
pub mod backups;
pub mod cli;
pub mod client_profiles;
pub mod diagnostics;
pub mod firewall;
@@ -38,9 +39,19 @@ pub fn build_api_router(state: AppState) -> Router {
.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))
.route("/system/cli", post(cli::execute_cli_handler))
.route("/system/cli/commands", get(cli::list_cli_commands_handler))
// Interfaces
.route("/interfaces", get(interfaces::list_interfaces_handler))
.route("/interfaces", post(interfaces::create_interface_handler))
.route(
"/interfaces/upstreams/preview",
post(interfaces::preview_upstream_handler),
)
.route(
"/interfaces/upstreams/import",
post(interfaces::import_upstream_handler),
)
.route("/interfaces/{id}", get(interfaces::get_interface_handler))
.route(
"/interfaces/{id}",
@@ -58,6 +69,10 @@ pub fn build_api_router(state: AppState) -> Router {
"/interfaces/{id}/disable",
post(interfaces::disable_interface_handler),
)
.route(
"/interfaces/{id}/restart",
post(interfaces::restart_interface_handler),
)
.route(
"/interfaces/{id}/status",
get(interfaces::interface_status_handler),
@@ -6,7 +6,9 @@ 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_core::types::wireguard::{
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
};
use nx9_wg_db::Store;
use std::str::FromStr;
use tower::ServiceExt;
@@ -38,9 +40,10 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
let interface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: srv_priv,
public_key: srv_pub,
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -0,0 +1,501 @@
//! Comprehensive Integration test suite for Interface Lifecycle Hardening:
//! - Interface deletion converges desired state and kernel state
//! - wg0 protection (deletion and disabling rejected via API & CLI)
//! - Reconciliation orphan detection and cleanup
//! - Desired-state read failure safety guard
//! - Interface restart lifecycle
//! - SPA Read-Only CLI Console allowlist and safety
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use chrono::Utc;
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
use nx9_wg_api::reconciliation::ReconciliationEngine;
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::routes::cli::{ExecuteCliRequest, build_safe_argv, scrub_secrets};
use nx9_wg_api::state::AppState;
use nx9_wg_core::config::AppConfig;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::wireguard::{
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
};
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use nx9_wg_network::SimulatedNetworkEngine;
use nx9_wireguard::{LiveInterfaceStats, SimulatedWireGuardEngine, WireGuardEngine};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::{TempDir, tempdir};
use tower::ServiceExt;
use uuid::Uuid;
async fn setup_test_context() -> (
TempDir,
Store,
AppState,
Arc<SimulatedWireGuardEngine>,
Arc<SimulatedNetworkEngine>,
ReconciliationEngine,
axum::Router,
String,
) {
let dir = tempdir().expect("create temp dir");
let db_path = dir.path().join("lifecycle_test.db");
let store = Store::connect(&db_path.to_string_lossy())
.await
.expect("connect to db");
store.migrate().await.expect("run migrations");
let config = AppConfig::default();
let opts = BootstrapOptions {
cli_password: Some("AdminSecret123!".to_string()),
..Default::default()
};
bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap");
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let state = AppState::with_engines(store.clone(), wg_engine.clone(), net_engine.clone());
let reconciler =
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
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();
(
dir,
store,
state,
wg_engine,
net_engine,
reconciler,
app,
session_cookie,
)
}
fn fixture_interface(name: &str, v4_cidr: &str) -> Interface {
let (priv_k, pub_k) = generate_keypair();
let now = Utc::now().naive_utc();
Interface {
id: Uuid::new_v4(),
name: name.to_string(),
role: InterfaceRole::Overlay,
private_key: priv_k,
public_key: pub_k,
listen_port: Some(51820),
address_v4: validate_cidr(v4_cidr).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,
}
}
fn fixture_peer(iface_id: Uuid, name: &str, v4_addr: &str) -> Peer {
let (priv_k, pub_k) = generate_keypair();
let now = Utc::now().naive_utc();
Peer {
id: Uuid::new_v4(),
interface_id: iface_id,
name: name.to_string(),
public_key: pub_k,
preshared_key: None,
private_key: Some(priv_k),
endpoint: None,
address_v4: Some(validate_cidr(v4_addr).unwrap()),
address_v6: None,
allowed_ips: "0.0.0.0/0".to_string(),
server_allowed_ips: None,
dns: Some("1.1.1.1".to_string()),
persistent_keepalive: Some(25),
mtu: Some(1420),
state: PeerState::Active,
peer_type: PeerType::RoadWarrior,
profile: PeerProfile::FullTunnel,
last_handshake_at: None,
expires_at: None,
created_at: now,
updated_at: now,
}
}
#[tokio::test]
async fn test_interface_delete_removes_kernel_state() {
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
// 1. Create desired interface
let iface = fixture_interface("custom0", "10.200.0.1/24");
store
.create_interface(&iface)
.await
.expect("create interface");
// 2. Sync to simulated kernel
wg_engine.sync_interface(&iface, &[]).await.expect("sync");
// 3. Verify kernel interface exists
let live = wg_engine.list_interfaces().await.unwrap();
assert!(live.contains(&"custom0".to_string()));
// 4. Delete via API
let 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(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 5. Verify DB object removed
let db_iface = store.get_interface(iface.id).await.unwrap();
assert!(db_iface.is_none());
// 6. Verify kernel interface removed
let live_after = wg_engine.list_interfaces().await.unwrap();
assert!(!live_after.contains(&"custom0".to_string()));
}
#[tokio::test]
async fn test_wg0_deletion_rejected() {
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
// 1. Create wg0 interface
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
store.create_interface(&wg0).await.expect("create wg0");
wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0");
// 2. Attempt deletion via API
let req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/interfaces/{}", wg0.id))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let val: Value = serde_json::from_slice(&body).unwrap();
assert!(val["error"]["message"].as_str().unwrap().contains("wg0"));
// 3. Confirm DB and kernel state remain intact
let db_wg0 = store.get_interface(wg0.id).await.unwrap();
assert!(db_wg0.is_some());
let live = wg_engine.list_interfaces().await.unwrap();
assert!(live.contains(&"wg0".to_string()));
}
#[tokio::test]
async fn test_wg0_disable_rejected() {
let (_dir, store, _state, _wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
// 1. Create wg0 interface
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
store.create_interface(&wg0).await.expect("create wg0");
// 2. Attempt disable via API
let req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{}/disable", wg0.id))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// 3. Confirm enabled remains true in DB
let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap();
assert!(db_wg0.enabled);
}
#[tokio::test]
async fn test_orphan_interface_reconciliation() {
let (_dir, store, _state, wg_engine, _net, reconciler, _app, _cookie) =
setup_test_context().await;
// 1. Create desired interface wg0
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
store.create_interface(&wg0).await.expect("create wg0");
wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0");
// 2. Inject orphan kernel-only interface (e.g. proton0)
wg_engine
.inject_interface_stats(LiveInterfaceStats {
name: "proton0".to_string(),
public_key: "OrphanPubKey123456789012345678901234567890=".to_string(),
listen_port: 51821,
fwmark: 0,
addresses: vec!["10.2.0.2/32".to_string()],
mtu: Some(1420),
is_up: true,
peers: vec![],
})
.await;
// 3. Verify kernel has both wg0 and proton0
let live = wg_engine.list_interfaces().await.unwrap();
assert!(live.contains(&"wg0".to_string()));
assert!(live.contains(&"proton0".to_string()));
// 4. Run reconciliation plan
let plan = reconciler.plan().await.expect("plan");
assert!(plan.has_drift);
let orphan_action = plan
.actions
.iter()
.find(|a| a.action_type == "delete_orphan_interface" && a.resource_id == "proton0");
assert!(
orphan_action.is_some(),
"Expected orphan removal action for proton0"
);
// 5. Run reconciliation apply
let report = reconciler.apply().await.expect("apply");
assert!(report.success);
// 6. Confirm kernel interface proton0 is removed, wg0 remains
let live_after = wg_engine.list_interfaces().await.unwrap();
assert!(live_after.contains(&"wg0".to_string()));
assert!(!live_after.contains(&"proton0".to_string()));
}
#[tokio::test]
async fn test_interface_restart_preserves_state() {
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
// 1. Create interface with peer
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
store.create_interface(&wg0).await.expect("create wg0");
let peer = fixture_peer(wg0.id, "mobile-alice", "10.100.0.5/32");
store.create_peer(&peer).await.expect("create peer");
// 2. Initial sync
wg_engine
.sync_interface(&wg0, &[peer.clone()])
.await
.expect("sync");
// 3. Call restart API
let req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{}/restart", wg0.id))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 4. Verify DB object remains identical
let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap();
assert_eq!(db_wg0.id, wg0.id);
assert_eq!(db_wg0.name, "wg0");
assert_eq!(db_wg0.address_v4, wg0.address_v4);
assert_eq!(db_wg0.public_key.as_str(), wg0.public_key.as_str());
// 5. Verify live kernel state converged with peer restored
let stats = wg_engine
.get_interface_stats("wg0")
.await
.unwrap()
.expect("wg0 stats");
assert_eq!(stats.name, "wg0");
assert_eq!(stats.peers.len(), 1);
assert_eq!(stats.peers[0].public_key, peer.public_key.as_str());
}
#[tokio::test]
async fn test_reconcile_does_not_delete_on_desired_state_read_failure() {
let (_dir, _store, state, wg_engine, net_engine, _rec, _app, _cookie) =
setup_test_context().await;
// 1. Inject live interface in kernel
wg_engine
.inject_interface_stats(LiveInterfaceStats {
name: "wg0".to_string(),
public_key: "Wg0PubKey12345678901234567890123456789012=".to_string(),
listen_port: 51820,
fwmark: 0,
addresses: vec!["10.100.0.1/24".to_string()],
mtu: Some(1420),
is_up: true,
peers: vec![],
})
.await;
// 2. Desired state is empty in DB
// Reconciler should abort rather than mass-deleting live interfaces
let reconciler =
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
let result = reconciler.apply().await;
assert!(result.is_err(), "Expected reconciliation to abort safely");
// 3. Confirm live interface was NOT deleted
let live = wg_engine.list_interfaces().await.unwrap();
assert!(live.contains(&"wg0".to_string()));
}
#[tokio::test]
async fn test_cli_console_readonly_whitelist() {
// 1. Test allowed read-only commands
let allowed_tests = vec![
ExecuteCliRequest {
command: "version".to_string(),
subcommand: None,
sub_subcommand: None,
target: None,
parameters: HashMap::new(),
},
ExecuteCliRequest {
command: "system".to_string(),
subcommand: Some("status".to_string()),
sub_subcommand: None,
target: None,
parameters: HashMap::new(),
},
ExecuteCliRequest {
command: "interface".to_string(),
subcommand: Some("list".to_string()),
sub_subcommand: None,
target: None,
parameters: HashMap::new(),
},
ExecuteCliRequest {
command: "interface".to_string(),
subcommand: Some("show".to_string()),
sub_subcommand: None,
target: Some("wg0".to_string()),
parameters: HashMap::new(),
},
ExecuteCliRequest {
command: "peer".to_string(),
subcommand: Some("list".to_string()),
sub_subcommand: None,
target: None,
parameters: HashMap::new(),
},
ExecuteCliRequest {
command: "live".to_string(),
subcommand: Some("interface".to_string()),
sub_subcommand: Some("list".to_string()),
target: None,
parameters: HashMap::new(),
},
ExecuteCliRequest {
command: "reconcile".to_string(),
subcommand: Some("status".to_string()),
sub_subcommand: None,
target: None,
parameters: HashMap::new(),
},
];
for req in allowed_tests {
let argv = build_safe_argv(&req);
assert!(
argv.is_ok(),
"Expected command {:?} to be allowed",
req.command
);
}
// 2. Test mutating commands are rejected
let mutating_tests = vec![
"create", "delete", "update", "set", "enable", "disable", "restart", "apply", "restore",
"reset", "remove", "flush", "add", "sh", "bash", "sudo",
];
for cmd in mutating_tests {
let req = ExecuteCliRequest {
command: cmd.to_string(),
subcommand: None,
sub_subcommand: None,
target: None,
parameters: HashMap::new(),
};
let argv = build_safe_argv(&req);
assert!(
argv.is_err(),
"Expected mutating command '{cmd}' to be rejected"
);
}
// 3. Test shell meta characters in target are rejected
let bad_targets = vec![
"-option",
"wg0; rm -rf /",
"wg0 | ls",
"wg0 & sleep 5",
"wg0 `whoami`",
"wg0 $(whoami)",
];
for bad in bad_targets {
let req = ExecuteCliRequest {
command: "interface".to_string(),
subcommand: Some("show".to_string()),
sub_subcommand: None,
target: Some(bad.to_string()),
parameters: HashMap::new(),
};
let argv = build_safe_argv(&req);
assert!(
argv.is_err(),
"Expected unsafe target '{bad}' to be rejected"
);
}
// 4. Test secrets scrubbing
let raw_text = r#"
Interface: wg0
PrivateKey: aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg==
PublicKey: dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA==
PresharedKey: c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE=
Addresses: 10.100.0.1/24
"#;
let scrubbed = scrub_secrets(raw_text);
assert!(!scrubbed.contains("aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg=="));
assert!(!scrubbed.contains("c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE="));
assert!(scrubbed.contains("[REDACTED]"));
assert!(scrubbed.contains("10.100.0.1/24"));
assert!(scrubbed.contains("dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA=="));
}
@@ -16,7 +16,9 @@ use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::types::network::Route;
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
use nx9_wg_core::types::wireguard::{
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
};
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
@@ -59,9 +61,10 @@ async fn test_drift_matrix_peer_lifecycle() {
let iface = Interface {
id: iface_id,
name: "nx9_test0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_key,
public_key: pub_key,
listen_port: 51820,
listen_port: Some(51820),
address_v4: validate_cidr("10.10.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -267,9 +270,10 @@ async fn test_restart_recovery_simulation() {
let iface = Interface {
id: Uuid::new_v4(),
name: "nx9_boot".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_key,
public_key: pub_key,
listen_port: 51820,
listen_port: Some(51820),
address_v4: validate_cidr("10.20.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -321,9 +325,10 @@ async fn test_secret_redaction_in_reconciliation_plan_and_report() {
let iface = Interface {
id: Uuid::new_v4(),
name: "nx9_sec".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_key,
public_key: pub_key,
listen_port: 51820,
listen_port: Some(51820),
address_v4: validate_cidr("10.30.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -363,9 +368,10 @@ async fn test_reconciliation_status_lifecycle_and_multi_cycle_idempotency() {
let iface = Interface {
id: Uuid::new_v4(),
name: "nx9_idem".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_key,
public_key: pub_key,
listen_port: 51820,
listen_port: Some(51820),
address_v4: validate_cidr("10.50.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -512,9 +518,10 @@ async fn test_interface_address_and_mtu_drift_lifecycle() {
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_key,
public_key: pub_key.clone(),
listen_port: 51820,
listen_port: Some(51820),
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
address_v6: Some(validate_cidr("fd00::1/64").unwrap()),
mtu: Some(1420),
@@ -3,7 +3,7 @@
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::types::wireguard::{Interface, InterfaceRole};
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use nx9_wg_network::SimulatedNetworkEngine;
@@ -31,9 +31,10 @@ async fn test_reconciliation_engine_drift_detection_and_apply() {
let iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_key,
public_key: pub_key,
listen_port: 51820,
listen_port: Some(51820),
address_v4: validate_cidr("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
+52 -4
View File
@@ -5,7 +5,10 @@ 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 nx9_wg_network::SimulatedNetworkEngine;
use nx9_wireguard::SimulatedWireGuardEngine;
use serde_json::{Value, json};
use std::sync::Arc;
use tower::ServiceExt;
async fn setup_test_app() -> (axum::Router, String) {
@@ -21,7 +24,11 @@ async fn setup_test_app() -> (axum::Router, String) {
.await
.expect("bootstrap");
let state = AppState::new(store);
let state = AppState::with_engines(
store,
Arc::new(SimulatedWireGuardEngine::new()),
Arc::new(SimulatedNetworkEngine::new()),
);
let app = build_api_router(state.clone());
// Login to get session ID
@@ -78,6 +85,7 @@ async fn test_public_health_and_version_endpoints() {
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");
assert_eq!(val["version"], "1.1.0");
}
#[tokio::test]
@@ -176,14 +184,54 @@ async fn test_interfaces_and_peers_rest_lifecycle() {
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()
// 6. Delete wg0 interface (must be rejected with 403 Forbidden)
let del_wg0_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();
let resp = app.clone().oneshot(del_wg0_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// 7. Restart wg0 interface (must succeed)
let restart_wg0_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/restart"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(restart_wg0_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 8. Create secondary interface and delete it (must succeed)
let create_sec_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "custom0",
"listen_port": 51822,
"address_v4": "10.200.0.1/24"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_sec_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let sec_val: Value = serde_json::from_slice(&body).unwrap();
let sec_id = sec_val["id"].as_str().unwrap();
let del_sec_req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/interfaces/{sec_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(del_sec_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
@@ -11,7 +11,9 @@ 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::network::Network;
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
use nx9_wg_core::types::wireguard::{
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
};
use nx9_wg_db::Store;
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
use nx9_wireguard::{
@@ -48,9 +50,10 @@ async fn setup_test_context() -> (AppState, Interface, Peer, String) {
let interface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: srv_priv,
public_key: srv_pub,
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.100.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -220,7 +223,7 @@ async fn test_learned_endpoint_and_handshake_telemetry_ingestion() {
.inject_interface_stats(LiveInterfaceStats {
name: iface.name.clone(),
public_key: iface.public_key.as_str().to_string(),
listen_port: iface.listen_port,
listen_port: iface.listen_port.unwrap_or(0),
fwmark: 0,
peers: live_peers,
addresses: vec!["10.100.0.1/24".to_string()],
@@ -270,7 +273,7 @@ async fn test_peer_allowed_ips_and_keepalive_kernel_drift() {
.inject_interface_stats(LiveInterfaceStats {
name: iface.name.clone(),
public_key: iface.public_key.as_str().to_string(),
listen_port: iface.listen_port,
listen_port: iface.listen_port.unwrap_or(0),
fwmark: 0,
peers: drifted_peers,
addresses: vec!["10.100.0.1/24".to_string()],
@@ -523,7 +526,7 @@ async fn test_interface_editing_persistence_and_key_preservation() {
// 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.listen_port, Some(51822));
assert_eq!(updated_iface.mtu, Some(1360));
assert_eq!(updated_iface.dns, Some("9.9.9.9".to_string()));
@@ -796,7 +799,7 @@ async fn test_peer_telemetry_enrichment_and_status_transitions() {
let live_iface = LiveInterfaceStats {
name: iface.name.clone(),
public_key: iface.public_key.to_string(),
listen_port: iface.listen_port,
listen_port: iface.listen_port.unwrap_or(0),
fwmark: 0,
peers: vec![live_peer],
addresses: vec!["10.100.0.1/24".to_string()],
@@ -0,0 +1,896 @@
//! Comprehensive Integration and Lifecycle Test Suite for NX9-WG Optional Upstream interfaces.
//!
//! Verifies:
//! - ProtonVPN-style .conf import, parsing, validation, persistence, and kernel synchronization
//! - wg0 overlay non-regression during all upstream operations
//! - Upstream enable, disable, restart, and deletion lifecycles
//! - Reconciliation engine drift detection, convergence, and orphan cleanup
//! - Zero secret leakage across API preview, import, status, list, and CLI
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use chrono::Utc;
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
use nx9_wg_api::collect_managed_wg_subnets;
use nx9_wg_api::reconciliation::ReconciliationEngine;
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::routes::cli::{ExecuteCliRequest, build_safe_argv, scrub_secrets};
use nx9_wg_api::state::AppState;
use nx9_wg_core::config::AppConfig;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::wireguard::{
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
};
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use nx9_wg_network::SimulatedNetworkEngine;
use nx9_wireguard::{LiveInterfaceStats, SimulatedWireGuardEngine, WireGuardEngine};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::{TempDir, tempdir};
use tower::ServiceExt;
use uuid::Uuid;
struct TestHarness {
_dir: TempDir,
store: Store,
_state: AppState,
wg_engine: Arc<SimulatedWireGuardEngine>,
_net_engine: Arc<SimulatedNetworkEngine>,
reconciler: Arc<ReconciliationEngine>,
app: axum::Router,
session_cookie: String,
}
async fn setup_test_harness() -> TestHarness {
let dir = tempdir().expect("create temp dir");
let db_path = dir.path().join("upstream_test.db");
let store = Store::connect(&db_path.to_string_lossy())
.await
.expect("connect to db");
store.migrate().await.expect("run migrations");
let config = AppConfig::default();
let opts = BootstrapOptions {
cli_password: Some("AdminSecret123!".to_string()),
..Default::default()
};
bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap admin");
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let state = AppState::with_engines(store.clone(), wg_engine.clone(), net_engine.clone());
let reconciler = Arc::new(ReconciliationEngine::new(
state.clone(),
wg_engine.clone(),
net_engine.clone(),
));
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();
let now = Utc::now().naive_utc();
let (wg0_priv, wg0_pub) = generate_keypair();
let wg0 = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: wg0_priv,
public_key: wg0_pub.clone(),
listen_port: Some(51820),
address_v4: validate_cidr("10.100.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(&wg0).await.unwrap();
let (client_priv, client_pub) = generate_keypair();
let client_peer = Peer {
id: Uuid::new_v4(),
interface_id: wg0.id,
name: "client-alice".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: client_pub,
private_key: Some(client_priv),
preshared_key: None,
endpoint: None,
allowed_ips: "10.100.0.2/32".to_string(),
server_allowed_ips: None,
address_v4: Some(validate_cidr("10.100.0.2/32").unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: Some(25),
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
store.create_peer(&client_peer).await.unwrap();
// Baseline reconciliation to converge initial network/firewall/wg state
reconciler.apply().await.unwrap();
TestHarness {
_dir: dir,
store,
_state: state,
wg_engine,
_net_engine: net_engine,
reconciler,
app,
session_cookie,
}
}
fn sample_proton_conf(priv_k_str: &str, provider_pub_k_str: &str) -> String {
format!(
r#"
# ProtonVPN WireGuard Configuration
[Interface]
PrivateKey = {}
Address = 10.2.0.2/32
DNS = 10.2.0.1
MTU = 1420
[Peer]
PublicKey = {}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 37.19.199.155:51820
PersistentKeepalive = 25
"#,
priv_k_str, provider_pub_k_str
)
}
#[tokio::test]
async fn test_proton0_import_and_kernel_sync() {
let harness = setup_test_harness().await;
let (priv_k, pub_k) = generate_keypair();
let (_, provider_pub_k) = generate_keypair();
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
// 1. Preview API endpoint (read-only, no side effects)
let preview_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces/upstreams/preview")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let preview_resp = harness.app.clone().oneshot(preview_req).await.unwrap();
assert_eq!(preview_resp.status(), StatusCode::OK);
let preview_body: Value = serde_json::from_slice(
&to_bytes(preview_resp.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(preview_body["name"], "proton0");
assert_eq!(preview_body["role"], "upstream");
assert_eq!(preview_body["address_v4"], "10.2.0.2/32");
assert_eq!(preview_body["dns"], "10.2.0.1");
assert_eq!(preview_body["provider_public_key"], provider_pub_k.as_str());
assert_eq!(preview_body["provider_endpoint"], "37.19.199.155:51820");
assert_eq!(preview_body["provider_allowed_ips"], "0.0.0.0/0, ::/0");
assert_eq!(preview_body["persistent_keepalive"], 25);
// Ensure secrets are never in response
assert!(preview_body.get("private_key").is_none());
assert!(preview_body.get("preshared_key").is_none());
// Verify DB still only has wg0 (preview didn't write to DB)
assert_eq!(harness.store.list_interfaces().await.unwrap().len(), 1);
// 2. Import API endpoint (transactional persistence + kernel sync)
let import_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces/upstreams/import")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
assert_eq!(import_resp.status(), StatusCode::OK);
let import_body: Value =
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
let iface_id = import_body["interface_id"].as_str().unwrap();
let peer_id = import_body["peer_id"].as_str().unwrap();
assert_eq!(import_body["name"], "proton0");
assert_eq!(import_body["role"], "upstream");
assert!(import_body.get("private_key").is_none());
assert!(import_body.get("preshared_key").is_none());
// 3. Verify SQLite desired state
let iface = harness
.store
.get_interface(Uuid::parse_str(iface_id).unwrap())
.await
.unwrap()
.expect("proton0 in db");
assert_eq!(iface.name, "proton0");
assert_eq!(iface.role, InterfaceRole::Upstream);
assert_eq!(iface.public_key.as_str(), pub_k.as_str());
let peers = harness
.store
.list_peers_for_interface(iface.id)
.await
.unwrap();
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].id.to_string(), peer_id);
assert_eq!(peers[0].public_key.as_str(), provider_pub_k.as_str());
assert_eq!(peers[0].allowed_ips, "0.0.0.0/0, ::/0");
// 4. Verify Kernel Simulation state
let kernel_stats = harness
.wg_engine
.get_interface_stats("proton0")
.await
.unwrap()
.expect("proton0 in kernel");
assert_eq!(kernel_stats.name, "proton0");
assert!(kernel_stats.is_up);
assert_eq!(kernel_stats.peers.len(), 1);
assert_eq!(kernel_stats.peers[0].public_key, provider_pub_k.as_str());
assert_eq!(
kernel_stats.peers[0].endpoint,
Some("37.19.199.155:51820".to_string())
);
assert_eq!(
kernel_stats.peers[0].allowed_ips,
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
);
assert_eq!(kernel_stats.peers[0].persistent_keepalive, Some(25));
}
#[tokio::test]
async fn test_wg0_non_regression_during_upstream_operations() {
let harness = setup_test_harness().await;
let (priv_k, _) = generate_keypair();
let (_, provider_pub_k) = generate_keypair();
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
// Import proton0
let import_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces/upstreams/import")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 1. wg0 remains Overlay
let wg0 = harness
.store
.get_interface_by_name("wg0")
.await
.unwrap()
.expect("wg0 exists");
assert_eq!(wg0.role, InterfaceRole::Overlay);
assert_eq!(wg0.address_v4.to_string(), "10.100.0.1/24");
// 2. wg0 peers unchanged and RoadWarrior AllowedIPs remain strictly /32
let wg0_peers = harness
.store
.list_peers_for_interface(wg0.id)
.await
.unwrap();
assert_eq!(wg0_peers.len(), 1);
assert_eq!(wg0_peers[0].name, "client-alice");
assert_eq!(
wg0_peers[0].server_wireguard_allowed_ips_for_role(InterfaceRole::Overlay),
"10.100.0.2/32"
);
// 3. Managed subnets for client NAT masquerade only includes Overlay interfaces
let subnets = collect_managed_wg_subnets(&harness.store).await.unwrap();
assert_eq!(subnets.len(), 1);
assert_eq!(subnets[0].to_string(), "10.100.0.1/24");
// proton0 address (10.2.0.2/32) is NOT in client NAT subnets!
assert!(!subnets.iter().any(|s| s.to_string().contains("10.2.0.2")));
// 4. Reconciliation plan reports zero drift
let plan = harness.reconciler.plan().await.unwrap();
assert!(!plan.has_drift, "Plan must be clean and fully converged");
}
#[tokio::test]
async fn test_upstream_restart_lifecycle() {
let harness = setup_test_harness().await;
let (priv_k, _) = generate_keypair();
let (_, provider_pub_k) = generate_keypair();
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
// Import proton0
let import_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces/upstreams/import")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
let import_body: Value =
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
let iface_id = import_body["interface_id"].as_str().unwrap();
// Restart proton0
let restart_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/restart"))
.header(header::COOKIE, &harness.session_cookie)
.body(Body::empty())
.unwrap();
let restart_resp = harness.app.clone().oneshot(restart_req).await.unwrap();
assert_eq!(restart_resp.status(), StatusCode::OK);
// Verify same interface ID in DB
let iface_after = harness
.store
.get_interface(Uuid::parse_str(iface_id).unwrap())
.await
.unwrap()
.expect("iface exists");
assert_eq!(iface_after.name, "proton0");
assert_eq!(iface_after.role, InterfaceRole::Upstream);
// Verify provider peer restored in kernel
let kernel_stats = harness
.wg_engine
.get_interface_stats("proton0")
.await
.unwrap()
.expect("proton0 live");
assert_eq!(kernel_stats.peers.len(), 1);
assert_eq!(kernel_stats.peers[0].public_key, provider_pub_k.as_str());
assert_eq!(
kernel_stats.peers[0].allowed_ips,
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
);
}
#[tokio::test]
async fn test_upstream_delete_lifecycle() {
let harness = setup_test_harness().await;
let (priv_k, _) = generate_keypair();
let (_, provider_pub_k) = generate_keypair();
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
// Import proton0
let import_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces/upstreams/import")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
let import_body: Value =
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
let iface_id = import_body["interface_id"].as_str().unwrap();
// Verify present in kernel before delete
assert!(
harness
.wg_engine
.get_interface_stats("proton0")
.await
.unwrap()
.is_some()
);
// Delete proton0
let del_req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/interfaces/{iface_id}"))
.header(header::COOKIE, &harness.session_cookie)
.body(Body::empty())
.unwrap();
let del_resp = harness.app.clone().oneshot(del_req).await.unwrap();
assert_eq!(del_resp.status(), StatusCode::OK);
// Verify absent from kernel
assert!(
harness
.wg_engine
.get_interface_stats("proton0")
.await
.unwrap()
.is_none()
);
// Verify absent from DB
assert!(
harness
.store
.get_interface(Uuid::parse_str(iface_id).unwrap())
.await
.unwrap()
.is_none()
);
// Verify wg0 remains untouched
assert!(
harness
.store
.get_interface_by_name("wg0")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn test_upstream_reconciliation_orphan_detection() {
let harness = setup_test_harness().await;
// Inject an orphan upstream interface into simulated kernel
harness
.wg_engine
.inject_interface_stats(LiveInterfaceStats {
name: "orphan_vpn0".to_string(),
public_key: "orphanpubkey12345".to_string(),
listen_port: 51830,
fwmark: 0,
peers: vec![],
addresses: vec!["10.99.0.1/24".to_string()],
mtu: Some(1420),
is_up: true,
})
.await;
// Detect orphan in plan
let plan = harness.reconciler.plan().await.unwrap();
assert!(plan.has_drift);
let orphan_action = plan
.actions
.iter()
.find(|a| a.resource_id == "orphan_vpn0")
.expect("orphan action in plan");
assert_eq!(orphan_action.action_type, "delete_orphan_interface");
// Apply cleanup
let report = harness.reconciler.apply().await.unwrap();
assert!(
report
.details
.iter()
.any(|d| d.contains("Removed orphan kernel interface 'orphan_vpn0'"))
);
// Verify orphan was deleted from kernel
assert!(
harness
.wg_engine
.get_interface_stats("orphan_vpn0")
.await
.unwrap()
.is_none()
);
// Verify wg0 remains active
assert!(
harness
.wg_engine
.get_interface_stats("wg0")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn test_upstream_secret_safety() {
let harness = setup_test_harness().await;
let (priv_k, _) = generate_keypair();
let (_, provider_pub_k) = generate_keypair();
let raw_priv = priv_k.as_str().to_string();
let conf = sample_proton_conf(&raw_priv, provider_pub_k.as_str());
// 1. Preview response secret check
let preview_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces/upstreams/preview")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let preview_resp = harness.app.clone().oneshot(preview_req).await.unwrap();
let preview_text = String::from_utf8(
to_bytes(preview_resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
!preview_text.contains(&raw_priv),
"PrivateKey leaked in preview response"
);
// 2. Import response secret check
let import_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces/upstreams/import")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
let import_text = String::from_utf8(
to_bytes(import_resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
!import_text.contains(&raw_priv),
"PrivateKey leaked in import response"
);
// 3. Read-only CLI output secret scrubber check
let scrubbed = scrub_secrets(&format!(
"private_key: {}\nPrivateKey = {}",
raw_priv, raw_priv
));
assert!(
!scrubbed.contains(&raw_priv),
"PrivateKey leaked past scrubber"
);
// 4. Safe argv builder allows read-only Upstream queries
let list_req = ExecuteCliRequest {
command: "interface".to_string(),
subcommand: Some("upstream".to_string()),
sub_subcommand: Some("list".to_string()),
target: None,
parameters: HashMap::new(),
};
let argv = build_safe_argv(&list_req).unwrap();
assert_eq!(argv, vec!["interface", "upstream", "list"]);
// 5. Prohibited mutating commands rejected by CLI allowlist
let import_cli_req = ExecuteCliRequest {
command: "interface".to_string(),
subcommand: Some("upstream".to_string()),
sub_subcommand: Some("import".to_string()),
target: Some("proton0".to_string()),
parameters: HashMap::new(),
};
assert!(build_safe_argv(&import_cli_req).is_err());
}
#[tokio::test]
async fn test_upstream_without_listen_port_does_not_conflict_with_wg0() {
let harness = setup_test_harness().await;
// 1. Verify wg0 already owns local UDP 51820
let wg0_initial = harness
.wg_engine
.get_interface_stats("wg0")
.await
.unwrap()
.unwrap();
assert_eq!(wg0_initial.listen_port, 51820);
// 2. Import proton0 from a configuration with no ListenPort
let (proton_priv, _) = generate_keypair();
let (_, provider_pub) = generate_keypair();
let conf = format!(
r#"
[Interface]
PrivateKey = {}
Address = 10.2.0.2/32
DNS = 10.2.0.1
[Peer]
PublicKey = {}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 37.19.199.155:51820
PersistentKeepalive = 25
"#,
proton_priv.as_str(),
provider_pub.as_str()
);
let import_req = Request::builder()
.uri("/api/v1/interfaces/upstreams/import")
.method("POST")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "proton0",
"config": conf
})
.to_string(),
))
.unwrap();
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let import_res: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(import_res["name"], "proton0");
assert_eq!(import_res["role"], "upstream");
assert_eq!(import_res["listen_port"], Value::Null);
assert_eq!(import_res["provider_endpoint"], "37.19.199.155:51820");
assert_eq!(import_res["provider_allowed_ips"], "0.0.0.0/0, ::/0");
// 3. Verify wg0 remains on UDP 51820 and unchanged
let wg0_db = harness
.store
.get_interface_by_name("wg0")
.await
.unwrap()
.unwrap();
assert_eq!(wg0_db.listen_port, Some(51820));
assert_eq!(wg0_db.role, InterfaceRole::Overlay);
// 4. Verify proton0 desired state in DB has listen_port = None
let proton_db = harness
.store
.get_interface_by_name("proton0")
.await
.unwrap()
.unwrap();
assert_eq!(proton_db.listen_port, None);
assert_eq!(proton_db.role, InterfaceRole::Upstream);
// 5. Verify simulated kernel state has both wg0 (51820) and proton0 (dynamic/0)
let live_wg0 = harness
.wg_engine
.get_interface_stats("wg0")
.await
.unwrap()
.unwrap();
assert_eq!(live_wg0.listen_port, 51820);
let live_proton = harness
.wg_engine
.get_interface_stats("proton0")
.await
.unwrap()
.unwrap();
assert_eq!(live_proton.listen_port, 0);
assert_eq!(live_proton.peers.len(), 1);
assert_eq!(
live_proton.peers[0].allowed_ips,
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
);
}
#[tokio::test]
async fn test_explicit_upstream_listen_port_is_preserved() {
let harness = setup_test_harness().await;
let (proton_priv, _) = generate_keypair();
let (_, provider_pub) = generate_keypair();
let conf = format!(
r#"
[Interface]
PrivateKey = {}
Address = 10.2.0.2/32
ListenPort = 45000
[Peer]
PublicKey = {}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 37.19.199.155:51820
"#,
proton_priv.as_str(),
provider_pub.as_str()
);
let import_req = Request::builder()
.uri("/api/v1/interfaces/upstreams/import")
.method("POST")
.header(header::COOKIE, &harness.session_cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "custom_vpn0",
"config": conf
})
.to_string(),
))
.unwrap();
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let import_res: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(import_res["listen_port"], 45000);
let iface_db = harness
.store
.get_interface_by_name("custom_vpn0")
.await
.unwrap()
.unwrap();
assert_eq!(iface_db.listen_port, Some(45000));
let live_custom = harness
.wg_engine
.get_interface_stats("custom_vpn0")
.await
.unwrap()
.unwrap();
assert_eq!(live_custom.listen_port, 45000);
}
#[tokio::test]
async fn test_upstream_missing_listen_port_no_false_drift() {
let harness = setup_test_harness().await;
// 1. Create upstream interface proton0 in DB with listen_port = None
let (priv_k, pub_k) = generate_keypair();
let (_, peer_pub) = generate_keypair();
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "proton0".to_string(),
role: InterfaceRole::Upstream,
private_key: priv_k,
public_key: pub_k.clone(),
listen_port: None,
address_v4: validate_cidr("10.2.0.2/32").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: Utc::now().naive_utc(),
updated_at: Utc::now().naive_utc(),
};
harness.store.create_interface(&iface).await.unwrap();
let peer = Peer {
id: Uuid::new_v4(),
interface_id: iface_id,
name: "proton0-provider".to_string(),
peer_type: PeerType::Server,
state: PeerState::Active,
public_key: peer_pub.clone(),
private_key: None,
preshared_key: None,
endpoint: Some("37.19.199.155:51820".to_string()),
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
server_allowed_ips: Some("0.0.0.0/0, ::/0".to_string()),
address_v4: None,
address_v6: None,
dns: None,
mtu: Some(1420),
persistent_keepalive: Some(25),
profile: PeerProfile::Custom,
expires_at: None,
last_handshake_at: None,
created_at: Utc::now().naive_utc(),
updated_at: Utc::now().naive_utc(),
};
harness.store.create_peer(&peer).await.unwrap();
// 2. Inject live kernel stats where the kernel has allocated an ephemeral dynamic port 54321
harness
.wg_engine
.inject_interface_stats(LiveInterfaceStats {
name: "proton0".to_string(),
public_key: pub_k.as_str().to_string(),
listen_port: 54321, // dynamic kernel-allocated port
fwmark: 0,
peers: vec![nx9_wireguard::LivePeerStats {
public_key: peer_pub.as_str().to_string(),
endpoint: Some("37.19.199.155:51820".to_string()),
rx_bytes: 100,
tx_bytes: 200,
last_handshake_at: None,
allowed_ips: vec!["0.0.0.0/0".to_string(), "::/0".to_string()],
persistent_keepalive: Some(25),
}],
addresses: vec!["10.2.0.2/32".to_string()],
mtu: Some(1420),
is_up: true,
})
.await;
// 3. Run reconciliation plan — must NOT flag drift for the dynamic listen port
let plan = harness.reconciler.plan().await.unwrap();
assert!(
!plan.has_drift,
"Expected zero drift for dynamic kernel listen port when desired listen_port is None, but got: {:?}",
plan.actions
);
assert_eq!(plan.interface_changes, 0);
assert_eq!(plan.peer_changes, 0);
}
@@ -6,7 +6,8 @@ use nx9_wg_core::types::firewall::{
};
use nx9_wg_core::types::network::Network;
use nx9_wg_core::types::wireguard::{
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey, WireGuardPublicKey,
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey,
WireGuardPublicKey,
};
use nx9_wg_db::Store;
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
@@ -61,13 +62,14 @@ async fn test_automatic_ip_allocation() {
let iface = Interface {
id: iface_id,
name: "wg50".to_string(),
role: InterfaceRole::Overlay,
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51850,
listen_port: Some(51850),
address_v4: "10.50.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -152,13 +154,14 @@ async fn test_peer_expiration_lifecycle() {
let iface = Interface {
id: iface_id,
name: "wg60".to_string(),
role: InterfaceRole::Overlay,
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51860,
listen_port: Some(51860),
address_v4: "10.60.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -288,13 +291,14 @@ async fn test_peer_firewall_and_port_ranges() {
let iface = Interface {
id: iface_id,
name: "wg70".to_string(),
role: InterfaceRole::Overlay,
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51870,
listen_port: Some(51870),
address_v4: "10.70.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),