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),
+29
View File
@@ -43,6 +43,26 @@ pub fn generate_keypair() -> (WireGuardPrivateKey, WireGuardPublicKey) {
)
}
/// Derive a WireGuard public key (x25519) from a base64-encoded private key.
pub fn derive_public_key(private_key_b64: &str) -> Result<WireGuardPublicKey> {
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use x25519_dalek::{PublicKey, StaticSecret};
let key_bytes = STANDARD
.decode(private_key_b64.trim())
.map_err(|e| Nx9Error::Validation(format!("invalid base64 private key: {e}")))?;
if key_bytes.len() != 32 {
return Err(Nx9Error::Validation(
"private key must be exactly 32 bytes (256 bits)".to_string(),
));
}
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&key_bytes);
let secret = StaticSecret::from(bytes);
let public = PublicKey::from(&secret);
Ok(WireGuardPublicKey::new(STANDARD.encode(public.as_bytes())))
}
/// Generate a WireGuard preshared key (32 random bytes, base64).
pub fn generate_preshared_key() -> WireGuardPresharedKey {
use base64::Engine;
@@ -106,6 +126,15 @@ mod tests {
let (priv_key, pub_key) = generate_keypair();
assert!(!priv_key.as_str().is_empty());
assert!(!pub_key.as_str().is_empty());
let derived_pub = derive_public_key(priv_key.as_str()).unwrap();
assert_eq!(derived_pub.as_str(), pub_key.as_str());
}
#[test]
fn test_derive_public_key_invalid() {
assert!(derive_public_key("not-base64!").is_err());
assert!(derive_public_key("dG9vLXNob3J0").is_err());
}
#[test]
+73 -1
View File
@@ -170,13 +170,52 @@ impl FromStr for PeerProfile {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InterfaceRole {
#[default]
Overlay,
Upstream,
}
impl InterfaceRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Overlay => "overlay",
Self::Upstream => "upstream",
}
}
}
impl Display for InterfaceRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl FromStr for InterfaceRole {
type Err = Nx9Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"overlay" => Ok(Self::Overlay),
"upstream" => Ok(Self::Upstream),
_ => Err(Nx9Error::Validation(format!(
"invalid InterfaceRole: {}",
s
))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interface {
pub id: Uuid,
pub name: String,
#[serde(default)]
pub role: InterfaceRole,
pub private_key: WireGuardPrivateKey,
pub public_key: WireGuardPublicKey,
pub listen_port: u16,
pub listen_port: Option<u16>,
pub address_v4: IpNet,
pub address_v6: Option<IpNet>,
pub mtu: Option<u16>,
@@ -285,6 +324,39 @@ impl Peer {
String::new()
}
/// Returns the effective server-side WireGuard AllowedIPs string for this peer,
/// scoped by the containing interface's role.
///
/// For `InterfaceRole::Upstream`:
/// Preserves the provider's configured AllowedIPs (including `0.0.0.0/0` and `::/0`)
/// for Generic Netlink cryptokey routing on the upstream interface.
///
/// For `InterfaceRole::Overlay`:
/// Delegates strictly to `server_wireguard_allowed_ips()`, guaranteeing that
/// RoadWarrior overlay client AllowedIPs are strictly derived from assigned tunnel IPs
/// and full-tunnel routes are never installed as server-side overlay peer AllowedIPs.
pub fn server_wireguard_allowed_ips_for_role(&self, role: InterfaceRole) -> String {
if role == InterfaceRole::Upstream {
let src = self
.server_allowed_ips
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(&self.allowed_ips);
let mut valid = Vec::new();
for item in src.split(',') {
let trimmed = item.trim();
if let Ok(net) = trimmed.parse::<IpNet>() {
valid.push(net.to_string());
}
}
if !valid.is_empty() {
return valid.join(", ");
}
}
self.server_wireguard_allowed_ips()
}
}
#[cfg(test)]
+7 -2
View File
@@ -22,7 +22,7 @@ Every connection opened by `Store` enforces:
2. `sessions` — Admin web sessions (`ON DELETE CASCADE`).
3. `login_attempts` — IP-based login attempt tracking for brute-force rate limiting.
4. `api_tokens` — Hashed API tokens for automation (`ON DELETE CASCADE`).
5. `interfaces` — Desired WireGuard interfaces (`wg0`, `wg1`, etc.), private/public keys, listen port, IPv4/IPv6 CIDRs, MTU, DNS.
5. `interfaces` — Desired WireGuard interfaces (`wg0`, `proton0`, etc.), role (`overlay`, `upstream`), private/public keys, optional listen port (`NULL` for dynamic kernel allocation), IPv4/IPv6 CIDRs, MTU, DNS.
6. `peers` — Desired WireGuard peer definitions, classifications (`road_warrior`, `site_gateway`, `server`, `relay`), states (`active`, `disabled`, `revoked`, `expired`), profiles (`full_tunnel`, `split_tunnel`, `custom`), public/private/preshared keys, AllowedIPs, endpoints, and persistent keepalives (`ON DELETE CASCADE`).
7. `networks` — Named network CIDRs for routing and organization.
8. `routes` — Desired kernel routing rules (`ON DELETE SET NULL`).
@@ -34,7 +34,12 @@ Every connection opened by `Store` enforces:
## Migration Strategy
- Migrations are defined in `crates/nx9-wg-db/migrations/` and embedded at compile time via `sqlx::migrate!("./migrations")`.
- Migrations are defined in `crates/nx9-wg-db/migrations/` and embedded at compile time via `sqlx::migrate!("./migrations")`:
- `0001_initial_schema.sql` — Initial relational schema.
- `0002_wiregui_schema.sql` — WireGUI capabilities and profile structures.
- `0003_server_endpoint_settings.sql` — Persistent server endpoint settings.
- `0004_interface_roles.sql` — Interface roles (`overlay` and `upstream`).
- `0005_optional_listen_port.sql` — Nullable `listen_port` for ephemeral kernel port selection.
- Migrations are executed automatically via `store.migrate().await?`.
- Migrations are tracked in the `_sqlx_migrations` table for idempotency.
@@ -0,0 +1,6 @@
-- 0004_interface_roles.sql
-- Explicit WireGuard Interface Role: Overlay (primary client network) or Upstream (third-party VPN tunnel)
ALTER TABLE interfaces ADD COLUMN role TEXT NOT NULL DEFAULT 'overlay' CHECK (role IN ('overlay', 'upstream'));
UPDATE interfaces SET role = 'overlay' WHERE role IS NULL OR role = '';
@@ -0,0 +1,34 @@
------------------------------------------------------------------------
-- nx9-wg SQLite Migration (0005_optional_listen_port.sql)
-- Allow nullable listen_port for Upstream interfaces with dynamic ports
------------------------------------------------------------------------
PRAGMA foreign_keys = OFF;
CREATE TABLE interfaces_dg_tmp (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'overlay' CHECK (role IN ('overlay', 'upstream')),
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
listen_port INTEGER,
ipv4_cidr TEXT NOT NULL,
ipv6_cidr TEXT,
mtu INTEGER,
dns TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
pre_up TEXT,
post_up TEXT,
pre_down TEXT,
post_down TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO interfaces_dg_tmp (id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr, mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at)
SELECT id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr, mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at FROM interfaces;
DROP TABLE interfaces;
ALTER TABLE interfaces_dg_tmp RENAME TO interfaces;
CREATE INDEX idx_interfaces_name ON interfaces(name);
PRAGMA foreign_keys = ON;
+19 -8
View File
@@ -4,7 +4,9 @@ use crate::error::{DbError, Result};
use crate::models::{format_datetime, parse_datetime};
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
use nx9_wg_core::types::wireguard::{
Interface, InterfaceRole, WireGuardPrivateKey, WireGuardPublicKey,
};
use sqlx::{Row, SqlitePool};
use std::str::FromStr;
use uuid::Uuid;
@@ -13,9 +15,10 @@ use uuid::Uuid;
fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
let id_str: String = r.try_get("id")?;
let name: String = r.try_get("name")?;
let role_str: Option<String> = r.try_get("role").ok();
let private_key_str: String = r.try_get("private_key")?;
let public_key_str: String = r.try_get("public_key")?;
let listen_port_i64: i64 = r.try_get("listen_port")?;
let listen_port_i64: Option<i64> = r.try_get("listen_port")?;
let ipv4_cidr_str: String = r.try_get("ipv4_cidr")?;
let ipv6_cidr_str: Option<String> = r.try_get("ipv6_cidr")?;
let mtu_i64: Option<i64> = r.try_get("mtu")?;
@@ -31,6 +34,11 @@ fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
let id = Uuid::parse_str(&id_str)
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{id_str}': {e}")))?;
let role = match role_str.as_deref() {
Some("upstream") => InterfaceRole::Upstream,
_ => InterfaceRole::Overlay,
};
let address_v4 = IpNet::from_str(&ipv4_cidr_str)
.map_err(|e| DbError::Validation(format!("invalid ipv4_cidr '{ipv4_cidr_str}': {e}")))?;
@@ -45,9 +53,10 @@ fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
Ok(Interface {
id,
name,
role,
private_key: WireGuardPrivateKey::new(private_key_str),
public_key: WireGuardPublicKey::new(public_key_str),
listen_port: listen_port_i64 as u16,
listen_port: listen_port_i64.map(|p| p as u16),
address_v4,
address_v6,
mtu: mtu_i64.map(|m| m as u16),
@@ -73,17 +82,18 @@ pub async fn create_interface(pool: &SqlitePool, iface: &Interface) -> Result<()
sqlx::query(
r#"
INSERT INTO interfaces (
id, name, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&id_str)
.bind(&iface.name)
.bind(iface.role.as_str())
.bind(iface.private_key.as_str())
.bind(iface.public_key.as_str())
.bind(iface.listen_port as i64)
.bind(iface.listen_port.map(|p| p as i64))
.bind(&ipv4_str)
.bind(ipv6_str)
.bind(iface.mtu.map(|m| m as i64))
@@ -162,16 +172,17 @@ pub async fn update_interface(pool: &SqlitePool, iface: &Interface) -> Result<()
let result = sqlx::query(
r#"
UPDATE interfaces
SET name = ?, private_key = ?, public_key = ?, listen_port = ?,
SET name = ?, role = ?, private_key = ?, public_key = ?, listen_port = ?,
ipv4_cidr = ?, ipv6_cidr = ?, mtu = ?, dns = ?, enabled = ?,
pre_up = ?, post_up = ?, pre_down = ?, post_down = ?, updated_at = ?
WHERE id = ?
"#,
)
.bind(&iface.name)
.bind(iface.role.as_str())
.bind(iface.private_key.as_str())
.bind(iface.public_key.as_str())
.bind(iface.listen_port as i64)
.bind(iface.listen_port.map(|p| p as i64))
.bind(&ipv4_str)
.bind(ipv6_str)
.bind(iface.mtu.map(|m| m as i64))
@@ -7,7 +7,7 @@ use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::types::network::{Network, Route};
use nx9_wg_core::types::wireguard::Interface;
use nx9_wg_core::types::wireguard::{Interface, InterfaceRole};
use nx9_wg_db::Store;
use std::net::IpAddr;
use std::str::FromStr;
@@ -116,9 +116,10 @@ async fn test_firewall_rule_crud_and_priority_ordering() {
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_k,
public_key: pub_k,
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: None,
@@ -4,7 +4,7 @@ use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
use nx9_wg_core::types::wireguard::{
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
};
use nx9_wg_db::Store;
use std::str::FromStr;
@@ -22,9 +22,10 @@ async fn test_interface_and_peer_crud_and_cascade() {
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.0.0.1/24").expect("valid cidr"),
address_v6: Some(IpNet::from_str("fd00::1/64").expect("valid cidr")),
mtu: Some(1420),
@@ -50,7 +51,8 @@ async fn test_interface_and_peer_crud_and_cascade() {
.expect("get_interface")
.expect("iface found");
assert_eq!(fetched.name, "wg0");
assert_eq!(fetched.listen_port, 51820);
assert_eq!(fetched.role, InterfaceRole::Overlay);
assert_eq!(fetched.listen_port, Some(51820));
assert_eq!(fetched.address_v4.to_string(), "10.0.0.1/24");
assert_eq!(fetched.mtu, Some(1420));
@@ -65,9 +67,10 @@ async fn test_interface_and_peer_crud_and_cascade() {
let dup_iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: 51821,
listen_port: Some(51821),
address_v4: IpNet::from_str("10.0.1.1/24").unwrap(),
address_v6: None,
mtu: None,
@@ -233,14 +236,37 @@ async fn test_interface_and_peer_crud_and_cascade() {
.expect("list peers");
assert_eq!(peer_list.len(), 1);
// Test cascade delete: deleting interface must cascade and delete its peers
// Test upstream interface with listen_port = None
let upstream_id = Uuid::new_v4();
let upstream_iface = Interface {
id: upstream_id,
name: "proton0".to_string(),
role: InterfaceRole::Upstream,
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: None,
address_v4: IpNet::from_str("10.2.0.2/32").unwrap(),
address_v6: None,
mtu: Some(1420),
dns: Some("10.2.0.1".to_string()),
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
store
.delete_interface(iface_id)
.create_interface(&upstream_iface)
.await
.expect("delete interface");
assert!(store.get_interface(iface_id).await.expect("get").is_none());
assert!(
store.get_peer(peer_id).await.expect("get").is_none(),
"peer must be cascade-deleted with interface"
);
.expect("create upstream interface");
let fetched_upstream = store
.get_interface(upstream_id)
.await
.expect("get")
.expect("upstream found");
assert_eq!(fetched_upstream.name, "proton0");
assert_eq!(fetched_upstream.role, InterfaceRole::Upstream);
assert_eq!(fetched_upstream.listen_port, None);
}
+10 -4
View File
@@ -91,7 +91,11 @@ impl ClientConfigBuilder {
// Check if already contains port
host_trimmed.to_string()
} else {
format!("{}:{}", host_trimmed, interface.listen_port)
format!(
"{}:{}",
host_trimmed,
interface.listen_port.unwrap_or(51820)
)
};
lines.push(format!("Endpoint = {endpoint}"));
@@ -144,7 +148,7 @@ mod tests {
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
use nx9_wg_core::types::wireguard::{PeerState, PeerType};
use nx9_wg_core::types::wireguard::{InterfaceRole, PeerState, PeerType};
use std::str::FromStr;
use uuid::Uuid;
@@ -158,9 +162,10 @@ mod tests {
let iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: srv_priv,
public_key: srv_pub.clone(),
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -231,9 +236,10 @@ mod tests {
let iface = 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),
+2 -2
View File
@@ -137,7 +137,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
.filter(|p| p.state == PeerState::Active)
.map(|p| {
let allowed_ips: Vec<String> = p
.server_wireguard_allowed_ips()
.server_wireguard_allowed_ips_for_role(interface.role)
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
@@ -163,7 +163,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
let stats = LiveInterfaceStats {
name: interface.name.clone(),
public_key: interface.public_key.as_str().to_string(),
listen_port: interface.listen_port,
listen_port: interface.listen_port.unwrap_or(0),
fwmark: 0,
peers: live_peers,
addresses,
+2
View File
@@ -6,6 +6,7 @@ pub mod error;
#[cfg(target_os = "linux")]
mod native_linux;
pub mod qr;
pub mod upstream_parser;
pub use config_builder::ClientConfigBuilder;
pub use engine::{
@@ -17,3 +18,4 @@ pub use qr::{
generate_qr_ascii, generate_qr_base64, generate_qr_data_url, generate_qr_png_bytes,
generate_qr_svg,
};
pub use upstream_parser::{ParsedUpstreamConfig, ParsedUpstreamPeer, UpstreamConfigParser};
+7 -2
View File
@@ -450,11 +450,16 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
let mut device_attrs: Vec<WireguardAttribute> = vec![
WireguardAttribute::IfName(interface.name.clone()),
WireguardAttribute::PrivateKey(private_key_bytes),
WireguardAttribute::ListenPort(interface.listen_port),
WireguardAttribute::Fwmark(0),
WireguardAttribute::Flags(WireguardDeviceFlags::ReplacePeers),
];
if let Some(port) = interface.listen_port {
if port != 0 {
device_attrs.push(WireguardAttribute::ListenPort(port));
}
}
// Build peer configurations for active peers only
let mut wg_peers = Vec::new();
for peer in peers.iter().filter(|p| p.state == PeerState::Active) {
@@ -484,7 +489,7 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
}
// Server-side Allowed IPs (cryptokey routing in Linux kernel)
let server_allowed_str = peer.server_wireguard_allowed_ips();
let server_allowed_str = peer.server_wireguard_allowed_ips_for_role(interface.role);
let allowed_ips = parse_allowed_ips(&server_allowed_str)?;
if !allowed_ips.is_empty() {
peer_attrs.push(WireguardPeerAttribute::Flags(
+464
View File
@@ -0,0 +1,464 @@
//! Third-party WireGuard configuration (.conf) parser and validator.
//!
//! Enforces:
//! - Exactly one `[Interface]` section containing `PrivateKey` and `Address`.
//! - Exactly one `[Peer]` section containing `PublicKey`, `Endpoint`, and `AllowedIPs`.
//! - Preservation of `0.0.0.0/0`, `::/0`, and specific CIDRs.
//! - Secret safety: Never exposes private or preshared keys in error messages.
use crate::error::{Result, WireGuardError};
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::crypto::derive_public_key;
use nx9_wg_core::types::wireguard::{
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPresharedKey,
WireGuardPrivateKey, WireGuardPublicKey,
};
use nx9_wg_core::validation::{validate_interface_name, validate_listen_port, validate_mtu};
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use uuid::Uuid;
/// Parsed provider peer definition from standard WireGuard config.
#[derive(Debug, Clone)]
pub struct ParsedUpstreamPeer {
pub name: String,
pub public_key: WireGuardPublicKey,
pub preshared_key: Option<WireGuardPresharedKey>,
pub endpoint: String,
pub allowed_ips: String,
pub persistent_keepalive: Option<u16>,
}
/// Fully validated Upstream interface configuration.
#[derive(Debug, Clone)]
pub struct ParsedUpstreamConfig {
pub interface_name: String,
pub private_key: WireGuardPrivateKey,
pub public_key: WireGuardPublicKey,
pub listen_port: Option<u16>,
pub address_v4: IpNet,
pub address_v6: Option<IpNet>,
pub dns: Option<String>,
pub mtu: Option<u16>,
pub peer: ParsedUpstreamPeer,
}
impl ParsedUpstreamConfig {
/// Convert parsed configuration into desired-state domain structs (`Interface`, `Peer`).
pub fn into_desired_state(self, interface_id: Uuid, peer_id: Uuid) -> (Interface, Peer) {
let now = Utc::now().naive_utc();
let iface = Interface {
id: interface_id,
name: self.interface_name,
role: InterfaceRole::Upstream,
private_key: self.private_key,
public_key: self.public_key,
listen_port: self.listen_port,
address_v4: self.address_v4,
address_v6: self.address_v6,
mtu: self.mtu,
dns: self.dns.clone(),
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
let peer = Peer {
id: peer_id,
interface_id,
name: self.peer.name,
peer_type: PeerType::Server,
state: PeerState::Active,
public_key: self.peer.public_key,
private_key: None,
preshared_key: self.peer.preshared_key,
endpoint: Some(self.peer.endpoint),
allowed_ips: self.peer.allowed_ips.clone(),
server_allowed_ips: Some(self.peer.allowed_ips),
address_v4: None,
address_v6: None,
dns: self.dns,
mtu: self.mtu,
persistent_keepalive: self.peer.persistent_keepalive,
profile: PeerProfile::Custom,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
(iface, peer)
}
}
/// Upstream WireGuard .conf parser.
#[derive(Debug, Clone, Default)]
pub struct UpstreamConfigParser;
impl UpstreamConfigParser {
/// Parse and validate a third-party WireGuard configuration string.
pub fn parse(raw_conf: &str, interface_name: &str) -> Result<ParsedUpstreamConfig> {
// 1. Validate interface name
validate_interface_name(interface_name)
.map_err(|e| WireGuardError::Config(format!("invalid interface name: {e}")))?;
if interface_name == "wg0" {
return Err(WireGuardError::Config(
"An Upstream interface cannot use the reserved name 'wg0'".to_string(),
));
}
// 2. Parse sections and key-values
let mut current_section: Option<String> = None;
let mut interface_section_count = 0;
let mut peer_section_count = 0;
let mut iface_private_key: Option<String> = None;
let mut iface_addresses: Vec<String> = Vec::new();
let mut iface_dns: Vec<String> = Vec::new();
let mut iface_mtu: Option<u16> = None;
let mut iface_listen_port: Option<u16> = None;
let mut peer_public_key: Option<String> = None;
let mut peer_preshared_key: Option<String> = None;
let mut peer_endpoint: Option<String> = None;
let mut peer_allowed_ips: Vec<String> = Vec::new();
let mut peer_keepalive: Option<u16> = None;
for (line_num, raw_line) in raw_conf.lines().enumerate() {
let line_idx = line_num + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
// Section header
if line.starts_with('[') && line.ends_with(']') {
let sec_name = line[1..line.len() - 1].trim();
if sec_name.eq_ignore_ascii_case("interface") {
interface_section_count += 1;
current_section = Some("Interface".to_string());
} else if sec_name.eq_ignore_ascii_case("peer") {
peer_section_count += 1;
current_section = Some("Peer".to_string());
} else {
return Err(WireGuardError::Config(format!(
"Unsupported section '[{sec_name}]' on line {line_idx}"
)));
}
continue;
}
// Key-Value pair
let (key, val) = match line.split_once('=') {
Some((k, v)) => (k.trim(), v.trim()),
None => {
return Err(WireGuardError::Config(format!(
"Invalid key-value syntax on line {line_idx}: '{line}'"
)));
}
};
// Remove trailing comments from value if any
let clean_val = match val.split_once('#').or_else(|| val.split_once(';')) {
Some((clean, _)) => clean.trim(),
None => val,
};
match current_section.as_deref() {
Some("Interface") => {
if key.eq_ignore_ascii_case("privatekey") {
if clean_val.is_empty() {
return Err(WireGuardError::Config(
"PrivateKey value cannot be empty".to_string(),
));
}
iface_private_key = Some(clean_val.to_string());
} else if key.eq_ignore_ascii_case("address") {
for addr in clean_val.split(',') {
let trimmed = addr.trim();
if !trimmed.is_empty() {
iface_addresses.push(trimmed.to_string());
}
}
} else if key.eq_ignore_ascii_case("dns") {
for d in clean_val.split(',') {
let trimmed = d.trim();
if !trimmed.is_empty() {
iface_dns.push(trimmed.to_string());
}
}
} else if key.eq_ignore_ascii_case("mtu") {
let parsed_mtu = clean_val.parse::<u16>().map_err(|_| {
WireGuardError::Config(format!(
"Invalid MTU '{clean_val}' on line {line_idx}"
))
})?;
validate_mtu(parsed_mtu).map_err(|e| {
WireGuardError::Config(format!("MTU validation failed: {e}"))
})?;
iface_mtu = Some(parsed_mtu);
} else if key.eq_ignore_ascii_case("listenport") {
let parsed_port = clean_val.parse::<u16>().map_err(|_| {
WireGuardError::Config(format!(
"Invalid ListenPort '{clean_val}' on line {line_idx}"
))
})?;
validate_listen_port(parsed_port).map_err(|e| {
WireGuardError::Config(format!("ListenPort validation failed: {e}"))
})?;
iface_listen_port = Some(parsed_port);
} else {
tracing::debug!(key = %key, "Ignoring unrecognized Interface setting in upstream config");
}
}
Some("Peer") => {
if key.eq_ignore_ascii_case("publickey") {
if clean_val.is_empty() {
return Err(WireGuardError::Config(
"PublicKey value cannot be empty".to_string(),
));
}
peer_public_key = Some(clean_val.to_string());
} else if key.eq_ignore_ascii_case("presharedkey") {
if !clean_val.is_empty() {
peer_preshared_key = Some(clean_val.to_string());
}
} else if key.eq_ignore_ascii_case("endpoint") {
if clean_val.is_empty() {
return Err(WireGuardError::Config(
"Endpoint value cannot be empty".to_string(),
));
}
peer_endpoint = Some(clean_val.to_string());
} else if key.eq_ignore_ascii_case("allowedips") {
for item in clean_val.split(',') {
let trimmed = item.trim();
if !trimmed.is_empty() {
peer_allowed_ips.push(trimmed.to_string());
}
}
} else if key.eq_ignore_ascii_case("persistentkeepalive") {
let ka = clean_val.parse::<u16>().map_err(|_| {
WireGuardError::Config(format!(
"Invalid PersistentKeepalive '{clean_val}' on line {line_idx}"
))
})?;
peer_keepalive = Some(ka);
} else {
tracing::debug!(key = %key, "Ignoring unrecognized Peer setting in upstream config");
}
}
None => {
return Err(WireGuardError::Config(format!(
"Configuration entry '{line}' found outside any section on line {line_idx}"
)));
}
_ => unreachable!(),
}
}
// 3. Section cardinality checks
if interface_section_count == 0 {
return Err(WireGuardError::Config(
"Missing [Interface] section in WireGuard configuration".to_string(),
));
}
if interface_section_count > 1 {
return Err(WireGuardError::Config(format!(
"Configuration contains {interface_section_count} [Interface] sections (expected exactly 1)"
)));
}
if peer_section_count == 0 {
return Err(WireGuardError::Config(
"An Upstream configuration must contain exactly one [Peer] section (found 0)"
.to_string(),
));
}
if peer_section_count > 1 {
return Err(WireGuardError::Config(format!(
"An Upstream configuration must contain exactly one [Peer] section (found {peer_section_count})"
)));
}
// 4. Validate Interface fields
let raw_priv_k = iface_private_key.ok_or_else(|| {
WireGuardError::Config(
"Missing required 'PrivateKey' in [Interface] section".to_string(),
)
})?;
let pub_k = derive_public_key(&raw_priv_k).map_err(|_| {
WireGuardError::Config(
"Invalid PrivateKey: failed to decode 32-byte WireGuard key".to_string(),
)
})?;
let priv_k = WireGuardPrivateKey::new(raw_priv_k);
if iface_addresses.is_empty() {
return Err(WireGuardError::Config(
"Missing required 'Address' in [Interface] section".to_string(),
));
}
let mut v4_addr: Option<IpNet> = None;
let mut v6_addr: Option<IpNet> = None;
for addr_str in &iface_addresses {
let net = IpNet::from_str(addr_str).map_err(|e| {
WireGuardError::Config(format!("Invalid Address '{addr_str}': {e}"))
})?;
match net {
IpNet::V4(_) => {
if v4_addr.is_none() {
v4_addr = Some(net);
}
}
IpNet::V6(_) => {
if v6_addr.is_none() {
v6_addr = Some(net);
}
}
}
}
let address_v4 = v4_addr.ok_or_else(|| {
WireGuardError::Config(
"Upstream configuration requires at least one IPv4 address in Address".to_string(),
)
})?;
let dns = if iface_dns.is_empty() {
None
} else {
for d in &iface_dns {
if d.parse::<IpAddr>().is_err() {
return Err(WireGuardError::Config(format!("Invalid DNS address '{d}'")));
}
}
Some(iface_dns.join(", "))
};
let listen_port = iface_listen_port;
// 5. Validate Peer fields
let raw_peer_pub = peer_public_key.ok_or_else(|| {
WireGuardError::Config("Missing required 'PublicKey' in [Peer] section".to_string())
})?;
// Validate public key format (32 bytes base64)
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
let pub_bytes = STANDARD.decode(raw_peer_pub.trim()).map_err(|_| {
WireGuardError::Config("Invalid Peer PublicKey: malformed base64".to_string())
})?;
if pub_bytes.len() != 32 {
return Err(WireGuardError::Config(
"Invalid Peer PublicKey: must be 32 bytes (256 bits)".to_string(),
));
}
let peer_pub = WireGuardPublicKey::new(raw_peer_pub);
let preshared_k = if let Some(psk_str) = peer_preshared_key {
let psk_bytes = STANDARD.decode(psk_str.trim()).map_err(|_| {
WireGuardError::Config("Invalid Peer PresharedKey: malformed base64".to_string())
})?;
if psk_bytes.len() != 32 {
return Err(WireGuardError::Config(
"Invalid Peer PresharedKey: must be 32 bytes (256 bits)".to_string(),
));
}
Some(WireGuardPresharedKey::new(psk_str))
} else {
None
};
let raw_endpoint = peer_endpoint.ok_or_else(|| {
WireGuardError::Config("Missing required 'Endpoint' in [Peer] section".to_string())
})?;
// Validate endpoint
validate_endpoint_syntax(&raw_endpoint)?;
if peer_allowed_ips.is_empty() {
return Err(WireGuardError::Config(
"Missing required 'AllowedIPs' in [Peer] section".to_string(),
));
}
let mut validated_allowed_ips = Vec::new();
for item in &peer_allowed_ips {
let net = IpNet::from_str(item).map_err(|e| {
WireGuardError::Config(format!("Invalid AllowedIPs CIDR '{item}': {e}"))
})?;
validated_allowed_ips.push(net.to_string());
}
Ok(ParsedUpstreamConfig {
interface_name: interface_name.to_string(),
private_key: priv_k,
public_key: pub_k,
listen_port,
address_v4,
address_v6: v6_addr,
dns,
mtu: iface_mtu,
peer: ParsedUpstreamPeer {
name: format!("{interface_name}-provider"),
public_key: peer_pub,
preshared_key: preshared_k,
endpoint: raw_endpoint,
allowed_ips: validated_allowed_ips.join(", "),
persistent_keepalive: peer_keepalive,
},
})
}
}
/// Validate endpoint format: IP:port or hostname:port
fn validate_endpoint_syntax(endpoint_str: &str) -> Result<()> {
let trimmed = endpoint_str.trim();
if trimmed.is_empty() {
return Err(WireGuardError::Config(
"Endpoint cannot be empty".to_string(),
));
}
if trimmed.parse::<SocketAddr>().is_ok() {
return Ok(());
}
if let Some(idx) = trimmed.rfind(':') {
let host = &trimmed[..idx];
let port_str = &trimmed[idx + 1..];
if host.is_empty() {
return Err(WireGuardError::Config(format!(
"Invalid endpoint '{trimmed}': missing host"
)));
}
let port = port_str.parse::<u16>().map_err(|_| {
WireGuardError::Config(format!("Invalid endpoint port '{port_str}' in '{trimmed}'"))
})?;
if port == 0 {
return Err(WireGuardError::Config(format!(
"Invalid endpoint port 0 in '{trimmed}'"
)));
}
return Ok(());
}
Err(WireGuardError::Config(format!(
"Invalid endpoint '{trimmed}': missing port (expected host:port)"
)))
}
@@ -0,0 +1,274 @@
//! Comprehensive test suite for third-party WireGuard Upstream .conf parsing and validation.
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::wireguard::InterfaceRole;
use nx9_wireguard::UpstreamConfigParser;
use uuid::Uuid;
#[test]
fn test_valid_proton_style_configuration() {
let (priv_k, pub_k) = generate_keypair();
let (_, peer_pub_k) = generate_keypair();
let conf = format!(
r#"
# ProtonVPN WireGuard Configuration
[Interface]
PrivateKey = {}
Address = 10.2.0.2/32
DNS = 10.2.0.1
MTU = 1420
[Peer]
# Server Node
PublicKey = {}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 37.19.199.155:51820
PersistentKeepalive = 25
"#,
priv_k.as_str(),
peer_pub_k.as_str()
);
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse proton config");
assert_eq!(parsed.interface_name, "proton0");
assert_eq!(parsed.public_key.as_str(), pub_k.as_str());
assert_eq!(parsed.address_v4.to_string(), "10.2.0.2/32");
assert_eq!(parsed.address_v6, None);
assert_eq!(parsed.dns, Some("10.2.0.1".to_string()));
assert_eq!(parsed.mtu, Some(1420));
assert_eq!(parsed.listen_port, None);
assert_eq!(parsed.peer.name, "proton0-provider");
assert_eq!(parsed.peer.public_key.as_str(), peer_pub_k.as_str());
assert_eq!(parsed.peer.endpoint, "37.19.199.155:51820");
assert_eq!(parsed.peer.allowed_ips, "0.0.0.0/0, ::/0");
assert_eq!(parsed.peer.persistent_keepalive, Some(25));
assert_eq!(parsed.peer.preshared_key, None);
let iface_id = Uuid::new_v4();
let peer_id = Uuid::new_v4();
let (iface, peer) = parsed.into_desired_state(iface_id, peer_id);
assert_eq!(iface.id, iface_id);
assert_eq!(iface.name, "proton0");
assert_eq!(iface.role, InterfaceRole::Upstream);
assert_eq!(iface.listen_port, None);
assert!(iface.enabled);
assert_eq!(peer.id, peer_id);
assert_eq!(peer.interface_id, iface_id);
assert_eq!(peer.name, "proton0-provider");
assert_eq!(peer.allowed_ips, "0.0.0.0/0, ::/0");
assert_eq!(
peer.server_wireguard_allowed_ips_for_role(InterfaceRole::Upstream),
"0.0.0.0/0, ::/0"
);
}
#[test]
fn test_omitted_listen_port_remains_unspecified() {
let (priv_k, _) = generate_keypair();
let (_, peer_pub_k) = generate_keypair();
let conf = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
assert_eq!(parsed.listen_port, None);
assert_eq!(parsed.peer.endpoint, "37.19.199.155:51820");
}
#[test]
fn test_explicit_listen_port_is_preserved() {
let (priv_k, _) = generate_keypair();
let (_, peer_pub_k) = generate_keypair();
let conf = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\nListenPort = 45000\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
assert_eq!(parsed.listen_port, Some(45000));
}
#[test]
fn test_endpoint_port_is_not_local_listen_port() {
let (priv_k, _) = generate_keypair();
let (_, peer_pub_k) = generate_keypair();
let conf = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
assert_eq!(parsed.listen_port, None);
assert!(parsed.peer.endpoint.ends_with(":51820"));
}
#[test]
fn test_dual_stack_address_and_preshared_key() {
let (priv_k, _) = generate_keypair();
let (_, peer_pub_k) = generate_keypair();
let (psk, _) = generate_keypair();
let conf = format!(
r#"
[Interface]
PrivateKey = {}
Address = 10.2.0.2/32, fd00::2/64
DNS = 10.2.0.1, 1.1.1.1
[Peer]
PublicKey = {}
PresharedKey = {}
AllowedIPs = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Endpoint = vpn.example.com:51820
"#,
priv_k.as_str(),
peer_pub_k.as_str(),
psk.as_str()
);
let parsed = UpstreamConfigParser::parse(&conf, "vpn0").expect("parse dual stack");
assert_eq!(parsed.address_v4.to_string(), "10.2.0.2/32");
assert_eq!(
parsed.address_v6.map(|ip| ip.to_string()),
Some("fd00::2/64".to_string())
);
assert_eq!(parsed.dns, Some("10.2.0.1, 1.1.1.1".to_string()));
assert!(parsed.peer.preshared_key.is_some());
assert_eq!(parsed.peer.preshared_key.unwrap().as_str(), psk.as_str());
}
#[test]
fn test_reject_wg0_interface_name() {
let (priv_k, _) = generate_keypair();
let (_, peer_pub_k) = generate_keypair();
let conf = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
let err = UpstreamConfigParser::parse(&conf, "wg0").unwrap_err();
assert!(err.to_string().contains("reserved name 'wg0'"));
}
#[test]
fn test_cardinality_rejections() {
let (priv_k, _) = generate_keypair();
let (_, peer_pub_k) = generate_keypair();
// 0 peers
let no_peers = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n",
priv_k.as_str()
);
assert!(UpstreamConfigParser::parse(&no_peers, "proton0").is_err());
// 2 peers
let multi_peers = format!(
r#"
[Interface]
PrivateKey = {}
Address = 10.2.0.2/32
[Peer]
PublicKey = {}
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:51820
[Peer]
PublicKey = {}
AllowedIPs = 0.0.0.0/0
Endpoint = 5.6.7.8:51820
"#,
priv_k.as_str(),
peer_pub_k.as_str(),
peer_pub_k.as_str()
);
let err = UpstreamConfigParser::parse(&multi_peers, "proton0").unwrap_err();
assert!(err.to_string().contains("exactly one [Peer] section"));
// Missing [Interface]
let no_iface = format!(
"[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&no_iface, "proton0").is_err());
}
#[test]
fn test_missing_and_malformed_fields_rejections() {
let (_, peer_pub_k) = generate_keypair();
// Missing PrivateKey
let no_priv = format!(
"[Interface]\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&no_priv, "proton0").is_err());
// Malformed PrivateKey
let bad_priv = format!(
"[Interface]\nPrivateKey = not-a-key\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&bad_priv, "proton0").is_err());
// Missing Address
let (priv_k, _) = generate_keypair();
let no_addr = format!(
"[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&no_addr, "proton0").is_err());
// Malformed Address
let bad_addr = format!(
"[Interface]\nPrivateKey = {}\nAddress = 999.999.999.999/99\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&bad_addr, "proton0").is_err());
// Missing PublicKey
let no_pub = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
priv_k.as_str()
);
assert!(UpstreamConfigParser::parse(&no_pub, "proton0").is_err());
// Missing Endpoint
let no_ep = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&no_ep, "proton0").is_err());
// Missing AllowedIPs
let no_aips = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nEndpoint = 1.2.3.4:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&no_aips, "proton0").is_err());
// Unknown section
let unknown_sec = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Unknown]\nKey = Val\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
priv_k.as_str(),
peer_pub_k.as_str()
);
assert!(UpstreamConfigParser::parse(&unknown_sec, "proton0").is_err());
}
@@ -3,7 +3,9 @@
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
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_wireguard::{
ClientConfigBuilder, SimulatedWireGuardEngine, WireGuardEngine, generate_qr_ascii,
generate_qr_data_url, generate_qr_png_bytes, generate_qr_svg,
@@ -23,9 +25,10 @@ async fn test_wireguard_engine_lifecycle_and_telemetry() {
let iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: srv_priv,
public_key: srv_pub.clone(),
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
@@ -138,9 +141,10 @@ fn test_client_config_and_qr_codes() {
let iface = 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),