Release v1.1.0
This commit is contained in:
1 parent
34227efd2b
commit
edc710cbd2
46 files changed
+5324
-190
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] PrivateKey = ... Address = 10.2.0.2/32 DNS = 10.2.0.1 [Peer] PublicKey = ... Endpoint = 37.19.199.155:51820 AllowedIPs = 0.0.0.0/0, ::/0 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
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user