Finalize nx9-wg production release

This commit is contained in:
thakares committed 2026-08-18 22:27:36 +05:30
1 parent 4dfe42fe68
commit d704c1e131
30 files changed
+2502 -370

No files matched your search

+197 -39
View File
@@ -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') || [];
+7 -30
View File
@@ -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)]
+41
View File
@@ -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(),
});
+65 -16
View File
@@ -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;