Finalize nx9-wg production release
This commit is contained in:
1 parent
4dfe42fe68
commit
d704c1e131
30 files changed
+2502
-370
No files matched your search
@@ -422,7 +422,7 @@
|
||||
</div>
|
||||
<div class="page-actions" style="display: flex; gap: 8px;">
|
||||
<button class="btn btn-secondary" onclick="renderPage('peers')">↻ Refresh Telemetry</button>
|
||||
<button class="btn btn-primary" onclick="openCreatePeerModal()">+ Add Peer</button>
|
||||
<button class="btn btn-primary" onclick="openAddPeerModal()">+ Add Peer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -717,12 +717,32 @@
|
||||
// ── Modals: Client Export & QR ─────────────────────────────────────────────
|
||||
window.openClientExportModal = async function(peerId) {
|
||||
let defaultEndpoint = '';
|
||||
let isDefaultFromSettings = false;
|
||||
try {
|
||||
const settings = await api('/system/settings');
|
||||
if (Array.isArray(settings)) {
|
||||
const srvEp = settings.find(s => s.key === 'server_endpoint')?.value;
|
||||
const pubEp = settings.find(s => s.key === 'public_endpoint')?.value;
|
||||
defaultEndpoint = (srvEp || pubEp || '').trim();
|
||||
const host = settings.find(s => s.key === 'wireguard.server_host')?.value?.trim();
|
||||
const port = settings.find(s => s.key === 'wireguard.server_port')?.value?.trim() || '51820';
|
||||
const enabledSetting = settings.find(s => s.key === 'wireguard.server_endpoint_enabled')?.value?.trim();
|
||||
const enabled = enabledSetting !== 'false' && enabledSetting !== '0';
|
||||
|
||||
if (enabled && host) {
|
||||
if (host.includes(':') && !host.startsWith('[')) {
|
||||
defaultEndpoint = `[${host}]:${port}`;
|
||||
} else {
|
||||
defaultEndpoint = `${host}:${port}`;
|
||||
}
|
||||
isDefaultFromSettings = true;
|
||||
} else {
|
||||
// Check legacy fallback
|
||||
const srvEp = settings.find(s => s.key === 'server_endpoint')?.value;
|
||||
const pubEp = settings.find(s => s.key === 'public_endpoint')?.value;
|
||||
const legacy = (srvEp || pubEp || '').trim();
|
||||
if (legacy) {
|
||||
defaultEndpoint = legacy;
|
||||
isDefaultFromSettings = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -758,8 +778,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<label class="form-label">Server Endpoint <span style="font-weight: normal; color: var(--text-muted); font-size: 11px;">(Host/IP:Port to reach this server; overrides settings if entered)</span></label>
|
||||
<input id="export-endpoint" type="text" class="form-input" value="${escapeHtml(defaultEndpoint)}" placeholder="e.g. 192.168.1.8:51820 or vpn.yourdomain.com:51820" oninput="refreshClientExport('${peerId}')" />
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<label class="form-label" style="margin-bottom: 0;">Server Endpoint</label>
|
||||
${isDefaultFromSettings ? '<span style="font-size: 11px; font-weight: 500; background: rgba(59, 130, 246, 0.15); color: var(--accent-primary, #3b82f6); border: 1px solid rgba(59, 130, 246, 0.3); padding: 2px 8px; border-radius: 9999px;">Default from Server Settings</span>' : ''}
|
||||
</div>
|
||||
<input id="export-endpoint" type="text" class="form-input" value="${escapeHtml(defaultEndpoint)}" placeholder="e.g. vpn.thakares.com:51820 or 203.0.113.10:51820" oninput="refreshClientExport('${peerId}')" />
|
||||
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">
|
||||
Public/reachable server address. Editable for one-off export overrides without modifying server settings.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -825,7 +851,7 @@
|
||||
<div style="color: var(--text-danger); font-size: 12px; text-align: center; padding: 14px; background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.2); border-radius: var(--radius-md); max-width: 320px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">⚠️ QR Export Notice</div>
|
||||
<div>${escapeHtml(errMsg)}</div>
|
||||
${!endpoint ? '<div style="margin-top: 8px; font-size: 11px; color: var(--text-secondary);">Tip: Enter your WireGuard server endpoint above (e.g. 192.168.1.8:51820 or public domain) or configure server_endpoint in Settings.</div>' : ''}
|
||||
${!endpoint ? '<div style="margin-top: 8px; font-size: 11px; color: var(--text-secondary);">Configure the WireGuard Server Endpoint in <a href="javascript:void(0)" onclick="closeModal(); renderPage(\'settings\');" style="color: var(--accent-primary, #3b82f6); text-decoration: underline;">Settings</a> or enter an endpoint above.</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -854,6 +880,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.switchExportTab = function(tab) {
|
||||
const qrView = document.getElementById('export-qr-view');
|
||||
const confView = document.getElementById('export-conf-view');
|
||||
@@ -1921,28 +1948,86 @@
|
||||
const settings = await api('/system/settings') || [];
|
||||
const settingList = Array.isArray(settings) ? settings : [];
|
||||
|
||||
const srvEpSetting = settingList.find(s => s.key === 'server_endpoint')?.value || '';
|
||||
const pubEpSetting = settingList.find(s => s.key === 'public_endpoint')?.value || '';
|
||||
const currentEndpoint = srvEpSetting || pubEpSetting || '';
|
||||
const hostSetting = settingList.find(s => s.key === 'wireguard.server_host')?.value?.trim();
|
||||
const portSetting = settingList.find(s => s.key === 'wireguard.server_port')?.value?.trim() || '51820';
|
||||
const enabledSetting = settingList.find(s => s.key === 'wireguard.server_endpoint_enabled')?.value?.trim();
|
||||
const isEndpointEnabled = enabledSetting !== 'false' && enabledSetting !== '0';
|
||||
|
||||
let currentHost = hostSetting || '';
|
||||
let currentPort = portSetting || '51820';
|
||||
|
||||
// Legacy fallback for initial rendering if wireguard.server_host is not set
|
||||
if (!currentHost) {
|
||||
const srvEp = settingList.find(s => s.key === 'server_endpoint')?.value?.trim();
|
||||
const pubEp = settingList.find(s => s.key === 'public_endpoint')?.value?.trim();
|
||||
const legacy = srvEp || pubEp || '';
|
||||
if (legacy) {
|
||||
if (legacy.startsWith('[') && legacy.includes(']')) {
|
||||
const closing = legacy.indexOf(']');
|
||||
currentHost = legacy.substring(1, closing);
|
||||
if (legacy.substring(closing + 1).startsWith(':')) {
|
||||
currentPort = legacy.substring(closing + 2);
|
||||
}
|
||||
} else if (legacy.includes(':') && !legacy.includes('::')) {
|
||||
const parts = legacy.split(':');
|
||||
currentHost = parts[0];
|
||||
currentPort = parts[1] || '51820';
|
||||
} else {
|
||||
currentHost = legacy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let initialPreview = 'Not configured';
|
||||
if (!isEndpointEnabled) {
|
||||
initialPreview = 'Disabled (Manual export override required)';
|
||||
} else if (currentHost) {
|
||||
if (currentHost.includes(':') && !currentHost.startsWith('[')) {
|
||||
initialPreview = `[${currentHost}]:${currentPort}`;
|
||||
} else {
|
||||
initialPreview = `${currentHost}:${currentPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1>Settings</h1>
|
||||
<div class="page-description">Appliance configuration, networking policies, and danger zone.</div>
|
||||
<div class="page-description">Appliance configuration, WireGuard server endpoint, networking policies, and danger zone.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-stack">
|
||||
<!-- Persistent WireGuard Server Endpoint Card -->
|
||||
<div class="card" style="border-left: 4px solid var(--accent-primary, #3b82f6);">
|
||||
<div class="card-header-title" style="margin-bottom: 4px;">WireGuard Server Endpoint (Client Reachable)</div>
|
||||
<div class="card-header-title" style="margin-bottom: 4px;">WireGuard Server Endpoint</div>
|
||||
<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 14px;">
|
||||
The publicly or locally reachable host and port where WireGuard clients connect (e.g. <code>192.168.1.8:51820</code> or <code>vpn.example.com:51820</code>). This value is automatically embedded into exported client configurations and QR codes.
|
||||
The configured endpoint is automatically used when generating WireGuard client configurations and QR codes. It can be overridden for an individual export without changing the global default.
|
||||
</div>
|
||||
<div id="server-endpoint-alert" style="display: none; margin-bottom: 12px;" class="alert-box"></div>
|
||||
<div style="display: flex; gap: 10px; max-width: 540px; align-items: center;">
|
||||
<input type="text" id="setting-server-endpoint" class="form-input" value="${escapeHtml(currentEndpoint)}" placeholder="e.g. 192.168.1.8:51820 or vpn.yourdomain.com:51820" />
|
||||
<button class="btn btn-primary" onclick="saveServerEndpoint()">Save Endpoint</button>
|
||||
<div class="form-grid-2" style="max-width: 680px; margin-bottom: 14px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Server Host / IP <span style="color: var(--text-danger);">*</span></label>
|
||||
<input type="text" id="setting-wg-server-host" class="form-input" value="${escapeHtml(currentHost)}" placeholder="e.g. vpn.thakares.com, 203.0.113.10, or 2001:db8::10" oninput="updateWgEndpointPreview()" />
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Public hostname or IP address (do not include port).</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client Endpoint Port <span style="color: var(--text-danger);">*</span></label>
|
||||
<input type="number" id="setting-wg-server-port" class="form-input" min="1" max="65535" value="${escapeHtml(currentPort)}" placeholder="51820" oninput="updateWgEndpointPreview()" />
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Public reachable UDP port (default: 51820).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; color: var(--text-primary);">
|
||||
<input type="checkbox" id="setting-wg-endpoint-enabled" ${isEndpointEnabled ? 'checked' : ''} onchange="updateWgEndpointPreview()" style="cursor: pointer;" />
|
||||
<span>Use as default peer endpoint</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="background: var(--bg-surface-raised, rgba(255,255,255,0.03)); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 10px 14px; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; max-width: 680px; box-sizing: border-box;">
|
||||
<div style="font-size: 12px; color: var(--text-secondary);">Effective Client Endpoint:</div>
|
||||
<code id="setting-wg-effective-preview" style="font-weight: 600; color: var(--accent-primary, #3b82f6);">${escapeHtml(initialPreview)}</code>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" onclick="saveServerEndpointSettings()">Save Server Endpoint</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1975,47 +2060,120 @@
|
||||
`;
|
||||
}
|
||||
|
||||
window.saveServerEndpoint = async function() {
|
||||
window.updateWgEndpointPreview = function() {
|
||||
const host = document.getElementById('setting-wg-server-host')?.value?.trim() || '';
|
||||
const port = document.getElementById('setting-wg-server-port')?.value?.trim() || '51820';
|
||||
const enabled = document.getElementById('setting-wg-endpoint-enabled')?.checked ?? true;
|
||||
const previewEl = document.getElementById('setting-wg-effective-preview');
|
||||
if (!previewEl) return;
|
||||
if (!enabled) {
|
||||
previewEl.textContent = 'Disabled (Manual export override required)';
|
||||
return;
|
||||
}
|
||||
if (!host) {
|
||||
previewEl.textContent = 'Not configured';
|
||||
return;
|
||||
}
|
||||
if (host.includes(':') && !host.startsWith('[')) {
|
||||
previewEl.textContent = `[${host}]:${port}`;
|
||||
} else {
|
||||
previewEl.textContent = `${host}:${port}`;
|
||||
}
|
||||
};
|
||||
|
||||
window.saveServerEndpointSettings = async function() {
|
||||
const alertBox = document.getElementById('server-endpoint-alert');
|
||||
if (alertBox) alertBox.style.display = 'none';
|
||||
|
||||
const inputVal = document.getElementById('setting-server-endpoint')?.value?.trim();
|
||||
if (!inputVal) {
|
||||
const hostInput = document.getElementById('setting-wg-server-host')?.value?.trim() || '';
|
||||
const portInput = document.getElementById('setting-wg-server-port')?.value?.trim() || '51820';
|
||||
const enabledInput = document.getElementById('setting-wg-endpoint-enabled')?.checked ?? true;
|
||||
|
||||
if (enabledInput && !hostInput) {
|
||||
if (alertBox) {
|
||||
alertBox.className = 'alert-box danger';
|
||||
alertBox.style.display = 'block';
|
||||
alertBox.textContent = '❌ Server endpoint cannot be empty. Specify host:port (e.g. 192.168.1.8:51820).';
|
||||
alertBox.textContent = '❌ Server Host / IP cannot be empty when default endpoint is enabled.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await api('/system/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
key: 'server_endpoint',
|
||||
value: inputVal,
|
||||
is_secret: false,
|
||||
description: 'Reachable WireGuard server host:port endpoint'
|
||||
})
|
||||
});
|
||||
|
||||
if (res && !res.error) {
|
||||
if (alertBox) {
|
||||
alertBox.className = 'alert-box success';
|
||||
alertBox.style.display = 'block';
|
||||
alertBox.textContent = '✅ Server endpoint saved successfully.';
|
||||
if (hostInput) {
|
||||
if (hostInput.includes(':') && !hostInput.startsWith('[')) {
|
||||
const isIpv6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/.test(hostInput);
|
||||
if (!isIpv6) {
|
||||
if (alertBox) {
|
||||
alertBox.className = 'alert-box danger';
|
||||
alertBox.style.display = 'block';
|
||||
alertBox.textContent = '❌ Server Host must not include a port. Please specify the port in the Client Endpoint Port field.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
setTimeout(() => renderPage('settings'), 1200);
|
||||
} else {
|
||||
const errMsg = extractErrorMessage(res);
|
||||
}
|
||||
|
||||
const portNum = parseInt(portInput, 10);
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (alertBox) {
|
||||
alertBox.className = 'alert-box danger';
|
||||
alertBox.style.display = 'block';
|
||||
alertBox.textContent = '❌ Failed to save endpoint: ' + errMsg;
|
||||
alertBox.textContent = '❌ Client Endpoint Port must be between 1 and 65535.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resHost = await api('/system/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
key: 'wireguard.server_host',
|
||||
value: hostInput,
|
||||
is_secret: false,
|
||||
description: 'Reachable WireGuard server host or IP'
|
||||
})
|
||||
});
|
||||
if (resHost && resHost.error) throw new Error(extractErrorMessage(resHost));
|
||||
|
||||
const resPort = await api('/system/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
key: 'wireguard.server_port',
|
||||
value: String(portNum),
|
||||
is_secret: false,
|
||||
description: 'Reachable WireGuard client endpoint port'
|
||||
})
|
||||
});
|
||||
if (resPort && resPort.error) throw new Error(extractErrorMessage(resPort));
|
||||
|
||||
const resEnabled = await api('/system/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
key: 'wireguard.server_endpoint_enabled',
|
||||
value: String(enabledInput),
|
||||
is_secret: false,
|
||||
description: 'Use server endpoint as default for peer exports'
|
||||
})
|
||||
});
|
||||
if (resEnabled && resEnabled.error) throw new Error(extractErrorMessage(resEnabled));
|
||||
|
||||
if (alertBox) {
|
||||
alertBox.className = 'alert-box success';
|
||||
alertBox.style.display = 'block';
|
||||
alertBox.textContent = '✅ WireGuard Server Endpoint settings saved successfully.';
|
||||
}
|
||||
setTimeout(() => renderPage('settings'), 1000);
|
||||
} catch (e) {
|
||||
if (alertBox) {
|
||||
alertBox.className = 'alert-box danger';
|
||||
alertBox.style.display = 'block';
|
||||
alertBox.textContent = '❌ Failed to save endpoint settings: ' + (e.message || 'Unknown error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.saveServerEndpoint = window.saveServerEndpointSettings;
|
||||
|
||||
|
||||
// ── Backups Management ──────────────────────────────────────────────────────
|
||||
async function renderBackupsPage(container) {
|
||||
const backups = await api('/backups') || [];
|
||||
|
||||
@@ -607,38 +607,15 @@ async fn resolve_server_endpoint(
|
||||
state: &AppState,
|
||||
query: &ClientProfileQuery,
|
||||
) -> ApiResult<String> {
|
||||
// 1. Explicit query parameter (server_endpoint or endpoint)
|
||||
if let Some(ep) = query
|
||||
let explicit_override = query
|
||||
.server_endpoint
|
||||
.as_deref()
|
||||
.or(query.endpoint.as_deref())
|
||||
{
|
||||
let trimmed = ep.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Persistent server_endpoint configuration from store
|
||||
if let Some(setting) = state.store.get_setting("server_endpoint").await? {
|
||||
let trimmed = setting.value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Persistent public_endpoint configuration from store
|
||||
if let Some(setting) = state.store.get_setting("public_endpoint").await? {
|
||||
let trimmed = setting.value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit actionable error if no reachable server endpoint is configured
|
||||
Err(ApiError::Validation(
|
||||
"No reachable WireGuard server endpoint is configured. Configure 'server_endpoint' in settings or provide --endpoint / query parameter.".to_string(),
|
||||
))
|
||||
.or(query.endpoint.as_deref());
|
||||
state
|
||||
.store
|
||||
.resolve_server_endpoint(explicit_override)
|
||||
.await
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
|
||||
@@ -121,6 +121,28 @@ pub async fn upsert_setting_handler(
|
||||
"Invalid server endpoint '{val_trimmed}'. Endpoint must be formatted as host:port (e.g. 192.168.1.8:51820 or vpn.domain.com:51820)"
|
||||
)));
|
||||
}
|
||||
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_HOST {
|
||||
if !val_trimmed.is_empty() {
|
||||
nx9_wg_core::validation::validate_server_host(val_trimmed)
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
}
|
||||
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_PORT {
|
||||
let port: u16 = val_trimmed.parse().map_err(|_| {
|
||||
ApiError::Validation(
|
||||
"Invalid server port: must be an integer between 1 and 65535".to_string(),
|
||||
)
|
||||
})?;
|
||||
nx9_wg_core::validation::validate_server_port(port)
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_ENDPOINT_ENABLED
|
||||
&& val_trimmed != "true"
|
||||
&& val_trimmed != "false"
|
||||
&& val_trimmed != "1"
|
||||
&& val_trimmed != "0"
|
||||
{
|
||||
return Err(ApiError::Validation(
|
||||
"Setting wireguard.server_endpoint_enabled must be 'true' or 'false'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_secret = payload.is_secret.unwrap_or(false);
|
||||
@@ -129,6 +151,25 @@ pub async fn upsert_setting_handler(
|
||||
.set_setting(key_trimmed, val_trimmed, is_secret)
|
||||
.await?;
|
||||
|
||||
// If updating server host/port/enabled, also keep legacy server_endpoint in sync if valid
|
||||
if (key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_HOST
|
||||
|| key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_PORT
|
||||
|| key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_ENDPOINT_ENABLED)
|
||||
&& let Ok(settings) = state.store.get_server_endpoint_settings().await
|
||||
&& settings.enabled
|
||||
&& !settings.host.trim().is_empty()
|
||||
{
|
||||
let formatted = nx9_wg_core::validation::format_endpoint(&settings.host, settings.port);
|
||||
let _ = state
|
||||
.store
|
||||
.set_setting(
|
||||
nx9_wg_core::types::settings::LEGACY_SETTING_SERVER_ENDPOINT,
|
||||
&formatted,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
state.broadcast(SystemEvent::SettingsChanged {
|
||||
key: key_trimmed.to_string(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::header::COOKIE;
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
@@ -14,24 +16,66 @@ pub struct WsAuthQuery {
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract the NX9 session identifier from the browser session cookie.
|
||||
///
|
||||
/// The WebUI authenticates through the HttpOnly `nx9_session` cookie.
|
||||
/// WebSocket upgrades do not pass through the normal REST authentication
|
||||
/// middleware, so the cookie must be authenticated explicitly here.
|
||||
fn extract_session_cookie(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|cookie| cookie.strip_prefix("nx9_session="))
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|session_id| !session_id.is_empty())
|
||||
}
|
||||
|
||||
/// Authenticate a WebSocket request.
|
||||
///
|
||||
/// Authentication precedence:
|
||||
///
|
||||
/// 1. Explicit API token: `?token=...`
|
||||
/// 2. Explicit session: `?session=...`
|
||||
/// 3. Browser session cookie: `nx9_session=...`
|
||||
///
|
||||
/// The browser WebUI uses the HttpOnly session cookie, so no credential
|
||||
/// needs to be exposed in the WebSocket URL.
|
||||
async fn authenticate_websocket(
|
||||
state: &AppState,
|
||||
query: &WsAuthQuery,
|
||||
headers: &HeaderMap,
|
||||
) -> bool {
|
||||
if let Some(raw_token) = query.token.as_deref() {
|
||||
return state.auth.authenticate_token(raw_token).await.is_ok();
|
||||
}
|
||||
|
||||
if let Some(session_id) = query.session.as_deref() {
|
||||
return state.auth.authenticate_session(session_id).await.is_ok();
|
||||
}
|
||||
|
||||
if let Some(session_id) = extract_session_cookie(headers) {
|
||||
return state.auth.authenticate_session(session_id).await.is_ok();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// GET /api/v1/ws
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<WsAuthQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Authenticate WebSocket connection via query parameters
|
||||
let authenticated = if let Some(ref raw_token) = query.token {
|
||||
state.auth.authenticate_token(raw_token).await.is_ok()
|
||||
} else if let Some(ref session_id) = query.session {
|
||||
state.auth.authenticate_session(session_id).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !authenticated {
|
||||
if !authenticate_websocket(&state, &query, &headers).await {
|
||||
return Err(ApiError::Unauthenticated(
|
||||
"WebSocket authentication required. Supply ?token=... or ?session=...".to_string(),
|
||||
"WebSocket authentication required. Supply a valid API token, session, or nx9_session cookie."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -42,11 +86,12 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let mut rx = state.event_tx.subscribe();
|
||||
|
||||
// Spawn background task to stream broadcast events to client
|
||||
// Stream broadcast events to the connected WebSocket client.
|
||||
let mut send_task = tokio::spawn(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
let msg = Message::Text(json.into());
|
||||
|
||||
if sender.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -54,7 +99,7 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
});
|
||||
|
||||
// Client receive loop to handle close/ping/pong
|
||||
// Receive loop handles client close frames and keeps the connection alive.
|
||||
let mut recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
if let Message::Close(_) = msg {
|
||||
@@ -63,9 +108,13 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
});
|
||||
|
||||
// If either task exits, abort the other
|
||||
// If either side terminates, stop the other task.
|
||||
tokio::select! {
|
||||
_ = (&mut send_task) => recv_task.abort(),
|
||||
_ = (&mut recv_task) => send_task.abort(),
|
||||
_ = (&mut send_task) => {
|
||||
recv_task.abort();
|
||||
}
|
||||
_ = (&mut recv_task) => {
|
||||
send_task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,6 +491,139 @@ async fn test_server_endpoint_persistence_validation_and_export_precedence() {
|
||||
assert!(conf_str.contains("Endpoint = vpn.wan-domain.org:51820"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_structured_server_endpoint_settings_api_and_peer_export() {
|
||||
let (state, _iface, peer, session_id) = setup_test_context().await;
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// 1. Invalid wireguard.server_host with embedded port is rejected with 422
|
||||
let invalid_host_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_host",
|
||||
"value": "vpn.thakares.com:51820", // embedded port!
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(invalid_host_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// 2. Invalid wireguard.server_port (0) is rejected with 422
|
||||
let invalid_port_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_port",
|
||||
"value": "0",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(invalid_port_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// 3. Valid wireguard.server_host and wireguard.server_port save successfully
|
||||
let set_host_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_host",
|
||||
"value": "vpn.thakares.com",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(set_host_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let set_port_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_port",
|
||||
"value": "51820",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(set_port_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 4. Export config automatically resolves Endpoint = vpn.thakares.com:51820
|
||||
let export_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(export_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
|
||||
assert!(conf_str.contains("Endpoint = vpn.thakares.com:51820"));
|
||||
|
||||
// 5. Export QR returns valid SVG
|
||||
let qr_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/qr", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(qr_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 6. Set IPv6 host -> exports [2001:db8::10]:51820
|
||||
let set_v6_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_host",
|
||||
"value": "2001:db8::10",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(set_v6_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let export_v6_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(export_v6_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
|
||||
assert!(conf_str.contains("Endpoint = [2001:db8::10]:51820"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
||||
let (state_orig, iface, peer, session_id) = setup_test_context().await;
|
||||
|
||||
Reference in new issue
Block a user