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;
+24
View File
@@ -29,3 +29,27 @@ impl std::fmt::Debug for Setting {
.finish()
}
}
/// Persistent WireGuard server endpoint configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerEndpointSettings {
pub host: String,
pub port: u16,
pub enabled: bool,
}
impl Default for ServerEndpointSettings {
fn default() -> Self {
Self {
host: String::new(),
port: 51820,
enabled: true,
}
}
}
pub const SETTING_SERVER_HOST: &str = "wireguard.server_host";
pub const SETTING_SERVER_PORT: &str = "wireguard.server_port";
pub const SETTING_SERVER_ENDPOINT_ENABLED: &str = "wireguard.server_endpoint_enabled";
pub const LEGACY_SETTING_SERVER_ENDPOINT: &str = "server_endpoint";
pub const LEGACY_SETTING_PUBLIC_ENDPOINT: &str = "public_endpoint";
+177
View File
@@ -269,6 +269,120 @@ pub fn validate_client_mtu(mtu: u16) -> Result<u16> {
Ok(mtu)
}
/// Validate server host or IP for WireGuard server endpoint settings.
///
/// Rules:
/// - Trim surrounding whitespace.
/// - Reject empty host.
/// - Accept valid DNS hostname.
/// - Accept valid IPv4 address.
/// - Accept valid IPv6 address (e.g. 2001:db8::10 or [2001:db8::10]).
/// - Reject embedded port syntax (e.g. example.com:51820, 192.168.1.1:51820, [::1]:51820)
/// with an explicit error indicating that port belongs in the separate port field.
pub fn validate_server_host(host: &str) -> Result<String> {
let trimmed = host.trim();
if trimmed.is_empty() {
return Err(Nx9Error::Validation(
"server host / IP cannot be empty".into(),
));
}
// Check if bracketed IPv6 (e.g. [2001:db8::10] or [2001:db8::10]:51820)
if trimmed.starts_with('[') {
if let Some(closing) = trimmed.find(']') {
let inside = &trimmed[1..closing];
if closing + 1 < trimmed.len() {
// Contains characters after bracket, likely a port
return Err(Nx9Error::Validation(
"server host must not include a port; specify the port in the Client Endpoint Port field".into(),
));
}
if inside.parse::<std::net::Ipv6Addr>().is_ok() {
return Ok(inside.to_string());
}
}
return Err(Nx9Error::Validation(format!(
"invalid IPv6 server host '{trimmed}'"
)));
}
// Check if direct unbracketed IPv6
if let Ok(ipv6) = trimmed.parse::<std::net::Ipv6Addr>() {
return Ok(ipv6.to_string());
}
// If it contains a colon and was not parsed as IPv6 above, it has an embedded port or is invalid
if trimmed.contains(':') {
return Err(Nx9Error::Validation(
"server host must not include a port; specify the port in the Client Endpoint Port field".into(),
));
}
// Check if IPv4
if let Ok(ipv4) = trimmed.parse::<std::net::Ipv4Addr>() {
return Ok(ipv4.to_string());
}
// Validate DNS hostname (RFC 1123 / RFC 952)
if trimmed.len() > 253 {
return Err(Nx9Error::Validation(
"server hostname exceeds maximum length of 253 characters".into(),
));
}
for label in trimmed.split('.') {
if label.is_empty() {
return Err(Nx9Error::Validation(format!(
"invalid hostname '{trimmed}': empty label"
)));
}
if label.len() > 63 {
return Err(Nx9Error::Validation(format!(
"invalid hostname '{trimmed}': label '{label}' exceeds 63 characters"
)));
}
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(Nx9Error::Validation(format!(
"invalid hostname '{trimmed}': contains invalid characters"
)));
}
if label.starts_with('-') || label.ends_with('-') {
return Err(Nx9Error::Validation(format!(
"invalid hostname '{trimmed}': label '{label}' cannot start or end with hyphen"
)));
}
}
Ok(trimmed.to_string())
}
/// Validate WireGuard client endpoint port (range 1..=65535).
pub fn validate_server_port(port: u16) -> Result<u16> {
if port == 0 {
return Err(Nx9Error::Validation(
"server endpoint port must be between 1 and 65535".into(),
));
}
Ok(port)
}
/// Format host and port into a standard WireGuard Endpoint string.
///
/// Formats IPv6 as `[host]:port` and hostname/IPv4 as `host:port`.
pub fn format_endpoint(host: &str, port: u16) -> String {
let trimmed = host.trim();
let unbracketed = trimmed
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(trimmed);
if unbracketed.parse::<std::net::Ipv6Addr>().is_ok() || unbracketed.contains(':') {
format!("[{}]:{}", unbracketed, port)
} else {
format!("{}:{}", unbracketed, port)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -355,4 +469,67 @@ mod tests {
assert!(validate_client_mtu(9001).is_err());
assert!(validate_client_mtu(65535).is_err());
}
#[test]
fn test_validate_server_host_valid() {
assert_eq!(
validate_server_host("vpn.thakares.com").unwrap(),
"vpn.thakares.com"
);
assert_eq!(
validate_server_host(" 203.0.113.10 ").unwrap(),
"203.0.113.10"
);
assert_eq!(
validate_server_host("2001:db8::10").unwrap(),
"2001:db8::10"
);
assert_eq!(
validate_server_host("[2001:db8::10]").unwrap(),
"2001:db8::10"
);
assert_eq!(
validate_server_host("vpn-node-01.internal").unwrap(),
"vpn-node-01.internal"
);
assert_eq!(validate_server_host("localhost").unwrap(), "localhost");
}
#[test]
fn test_validate_server_host_invalid() {
assert!(validate_server_host("").is_err());
assert!(validate_server_host(" ").is_err());
// Embedded ports rejected
assert!(validate_server_host("vpn.thakares.com:51820").is_err());
assert!(validate_server_host("203.0.113.10:51820").is_err());
assert!(validate_server_host("[2001:db8::10]:51820").is_err());
// Invalid hostname characters
assert!(validate_server_host("vpn$host.com").is_err());
assert!(validate_server_host("-invalid.com").is_err());
}
#[test]
fn test_validate_server_port() {
assert_eq!(validate_server_port(1).unwrap(), 1);
assert_eq!(validate_server_port(51820).unwrap(), 51820);
assert_eq!(validate_server_port(65535).unwrap(), 65535);
assert!(validate_server_port(0).is_err());
}
#[test]
fn test_format_endpoint() {
assert_eq!(
format_endpoint("vpn.thakares.com", 51820),
"vpn.thakares.com:51820"
);
assert_eq!(format_endpoint("203.0.113.10", 51820), "203.0.113.10:51820");
assert_eq!(
format_endpoint("2001:db8::10", 51820),
"[2001:db8::10]:51820"
);
assert_eq!(
format_endpoint("[2001:db8::10]", 51820),
"[2001:db8::10]:51820"
);
}
}
+11 -10
View File
@@ -1,12 +1,12 @@
# nx9-db — SQLite Persistence Layer
# nx9-wg-db — SQLite Persistence Layer
`nx9-db` provides the authoritative SQLite persistence layer for the `nx9-wg` native Rust WireGuard management system.
`nx9-wg-db` provides the authoritative SQLite persistence layer for the `nx9-wg` native Rust WireGuard management system.
## Architectural Boundaries
- **Authoritative State**: SQLite is the authoritative persistent store for `nx9-wg` desired state. It stores what the system intends the network, interfaces, peers, routes, firewall rules, administrator credentials, sessions, tokens, and settings to be.
- **Separation of Concerns**: SQLite records desired configuration only. Live kernel state (WireGuard interface status, handshake counters, packet counters, live nftables rules, live kernel routes) is queried directly from Linux kernel subsystems in later phases.
- **SQL Encapsulation**: All SQL queries, SQLite connection lifecycle, migrations, and row conversions are strictly encapsulated inside `nx9-db`. Neither `nx9-core`, `nx9-api`, `nx9-ui`, `nx9-wireguard`, nor `nx9-network` issue SQL directly.
- **Authoritative State**: SQLite is the authoritative persistent store for `nx9-wg` desired state. It stores what the system intends the network, interfaces, peers, routes, firewall rules, administrator credentials, sessions, tokens, client profiles, and settings to be.
- **Separation of Concerns**: SQLite records desired configuration only. Live kernel state (WireGuard interface status, handshake counters, packet counters, live nftables rules, live kernel routes) is queried directly from Linux kernel subsystems.
- **SQL Encapsulation**: All SQL queries, SQLite connection lifecycle, migrations, and row conversions are strictly encapsulated inside `nx9-wg-db`. Neither `nx9-wg-core`, `nx9-wg-api`, `nx9-wg-ui`, `nx9-wireguard`, nor `nx9-wg-network` issue SQL directly.
## SQLite Configuration
@@ -16,7 +16,7 @@ Every connection opened by `Store` enforces:
- `PRAGMA busy_timeout = 5000` — 5-second busy timeout to avoid contention errors.
- `PRAGMA synchronous = NORMAL` — Optimal reliability and performance in WAL mode.
## Database Schema (12 Tables)
## Database Schema (13 Tables)
1. `admin` — Single administrator identity (`CHECK (id = 1)`), Argon2id password hash, TOTP secrets, and login timestamp.
2. `sessions` — Admin web sessions (`ON DELETE CASCADE`).
@@ -27,20 +27,21 @@ Every connection opened by `Store` enforces:
7. `networks` — Named network CIDRs for routing and organization.
8. `routes` — Desired kernel routing rules (`ON DELETE SET NULL`).
9. `firewall_rules` — Desired firewall policy rules with priorities and directions (`in`, `out`, `forward`).
10. `settings` — Key-value system settings with secret redaction support.
10. `settings` — Key-value system settings with secret redaction support and structured WireGuard server endpoint keys.
11. `audit_events` — Append-only operational audit log with event filtering and pagination.
12. `backups` — Backup metadata and manifest checksum records.
13. `client_profiles` — Device, connection, and MTU transport profile specifications with built-in protections.
## Migration Strategy
- Migrations are defined in `crates/nx9-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")`.
- Migrations are executed automatically via `store.migrate().await?`.
- Migrations are tracked in the `_sqlx_migrations` table for idempotency.
## Usage in Code
```rust
use nx9_db::Store;
use nx9_wg_db::Store;
use std::path::Path;
#[tokio::main]
@@ -63,5 +64,5 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Tests use isolated in-memory or temporary file SQLite instances:
```bash
cargo test -p nx9-db
cargo test -p nx9-wg-db
```
+256 -1
View File
@@ -3,7 +3,11 @@
use crate::error::{DbError, Result};
use crate::models::{format_datetime, parse_datetime};
use chrono::Utc;
use nx9_wg_core::types::settings::Setting;
use nx9_wg_core::types::settings::{
LEGACY_SETTING_PUBLIC_ENDPOINT, LEGACY_SETTING_SERVER_ENDPOINT,
SETTING_SERVER_ENDPOINT_ENABLED, SETTING_SERVER_HOST, SETTING_SERVER_PORT,
ServerEndpointSettings, Setting,
};
use sqlx::{Row, SqlitePool};
/// Retrieve a setting by its key.
@@ -99,3 +103,254 @@ pub async fn list_settings(pool: &SqlitePool) -> Result<Vec<Setting>> {
Ok(list)
}
/// Retrieve structured server endpoint settings from the database with legacy fallback.
pub async fn get_server_endpoint_settings(pool: &SqlitePool) -> Result<ServerEndpointSettings> {
let host_opt = get_setting_value(pool, SETTING_SERVER_HOST).await?;
let port_opt = get_setting_value(pool, SETTING_SERVER_PORT).await?;
let enabled_opt = get_setting_value(pool, SETTING_SERVER_ENDPOINT_ENABLED).await?;
let enabled = enabled_opt
.as_deref()
.map(|v| {
let t = v.trim();
t.parse::<bool>().unwrap_or_else(|_| t == "1")
})
.unwrap_or(true);
let port = port_opt
.as_deref()
.and_then(|v| v.trim().parse::<u16>().ok())
.filter(|&p| p > 0)
.unwrap_or(51820);
if let Some(host) = host_opt.filter(|h| !h.trim().is_empty()) {
return Ok(ServerEndpointSettings {
host: host.trim().to_string(),
port,
enabled,
});
}
// Legacy fallback: inspect server_endpoint
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_SERVER_ENDPOINT).await? {
let trimmed = legacy.trim();
if !trimmed.is_empty() {
let (legacy_host, legacy_port) = split_host_port(trimmed, port);
return Ok(ServerEndpointSettings {
host: legacy_host,
port: legacy_port,
enabled,
});
}
}
// Legacy fallback: inspect public_endpoint
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await? {
let trimmed = legacy.trim();
if !trimmed.is_empty() {
let (legacy_host, legacy_port) = split_host_port(trimmed, port);
return Ok(ServerEndpointSettings {
host: legacy_host,
port: legacy_port,
enabled,
});
}
}
Ok(ServerEndpointSettings {
host: String::new(),
port,
enabled,
})
}
/// Persist structured server endpoint settings.
pub async fn set_server_endpoint_settings(
pool: &SqlitePool,
settings: &ServerEndpointSettings,
) -> Result<()> {
let host_trimmed = settings.host.trim();
if settings.enabled && !host_trimmed.is_empty() {
nx9_wg_core::validation::validate_server_host(host_trimmed)?;
nx9_wg_core::validation::validate_server_port(settings.port)?;
}
set_setting(pool, SETTING_SERVER_HOST, host_trimmed, false).await?;
set_setting(pool, SETTING_SERVER_PORT, &settings.port.to_string(), false).await?;
set_setting(
pool,
SETTING_SERVER_ENDPOINT_ENABLED,
&settings.enabled.to_string(),
false,
)
.await?;
// Synchronize legacy server_endpoint setting for backwards compatibility
if settings.enabled && !host_trimmed.is_empty() {
let formatted = nx9_wg_core::validation::format_endpoint(host_trimmed, settings.port);
set_setting(pool, LEGACY_SETTING_SERVER_ENDPOINT, &formatted, false).await?;
} else {
delete_setting(pool, LEGACY_SETTING_SERVER_ENDPOINT).await?;
delete_setting(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await?;
}
Ok(())
}
/// Authoritative server endpoint resolver.
///
/// Precedence:
/// 1. Explicit endpoint override (if non-empty)
/// 2. Persistent `wireguard.server_*` settings (when enabled and host non-empty)
/// 3. Legacy `server_endpoint` setting (if non-empty)
/// 4. Legacy `public_endpoint` setting (if non-empty)
/// 5. Actionable error explaining how to configure server endpoint or provide `--endpoint`.
pub async fn resolve_server_endpoint(
pool: &SqlitePool,
explicit_override: Option<&str>,
) -> Result<String> {
// 1. Explicit endpoint override
if let Some(ep) = explicit_override {
let trimmed = ep.trim();
if !trimmed.is_empty() {
return parse_and_normalize_endpoint(trimmed);
}
}
// Check enabled toggle
let enabled_opt = get_setting_value(pool, SETTING_SERVER_ENDPOINT_ENABLED).await?;
let enabled = enabled_opt
.as_deref()
.map(|v| {
let t = v.trim();
t.parse::<bool>().unwrap_or_else(|_| t == "1")
})
.unwrap_or(true);
if !enabled {
return Err(DbError::Validation(
"No reachable WireGuard server endpoint is configured. Configure WireGuard Server Endpoint in Settings or provide --endpoint.".to_string(),
));
}
// 2. Persistent wireguard.server_* settings
let host_opt = get_setting_value(pool, SETTING_SERVER_HOST).await?;
let port_opt = get_setting_value(pool, SETTING_SERVER_PORT).await?;
let port = port_opt
.as_deref()
.and_then(|v| v.trim().parse::<u16>().ok())
.filter(|&p| p > 0)
.unwrap_or(51820);
if let Some(host) = host_opt.filter(|h| !h.trim().is_empty()) {
let validated_host = nx9_wg_core::validation::validate_server_host(&host)?;
let validated_port = nx9_wg_core::validation::validate_server_port(port)?;
return Ok(nx9_wg_core::validation::format_endpoint(
&validated_host,
validated_port,
));
}
// 3. Legacy server_endpoint fallback
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_SERVER_ENDPOINT).await? {
let trimmed = legacy.trim();
if !trimmed.is_empty() {
return parse_and_normalize_endpoint(trimmed);
}
}
// 4. Legacy public_endpoint fallback
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await? {
let trimmed = legacy.trim();
if !trimmed.is_empty() {
return parse_and_normalize_endpoint(trimmed);
}
}
// 5. Actionable error
Err(DbError::Validation(
"No reachable WireGuard server endpoint is configured. Configure WireGuard Server Endpoint in Settings or provide --endpoint.".to_string(),
))
}
fn split_host_port(s: &str, default_port: u16) -> (String, u16) {
let trimmed = s.trim();
if trimmed.starts_with('[')
&& let Some(closing) = trimmed.find(']')
{
let host_part = &trimmed[1..closing];
let rest = &trimmed[closing + 1..];
if let Some(port_str) = rest.strip_prefix(':')
&& let Ok(port) = port_str.parse::<u16>()
&& port > 0
{
return (host_part.to_string(), port);
}
return (host_part.to_string(), default_port);
}
if let Ok(ipv6) = trimmed.parse::<std::net::Ipv6Addr>() {
return (ipv6.to_string(), default_port);
}
if let Some(last_colon) = trimmed.rfind(':') {
let host_part = &trimmed[..last_colon];
let port_part = &trimmed[last_colon + 1..];
if let Ok(port) = port_part.parse::<u16>()
&& port > 0
{
return (host_part.to_string(), port);
}
}
(trimmed.to_string(), default_port)
}
fn parse_and_normalize_endpoint(ep: &str) -> Result<String> {
let trimmed = ep.trim();
if trimmed.is_empty() {
return Err(DbError::Validation("endpoint cannot be empty".into()));
}
if trimmed.starts_with('[')
&& let Some(closing) = trimmed.find(']')
{
let host_part = &trimmed[1..closing];
let ipv6 = host_part.parse::<std::net::Ipv6Addr>().map_err(|e| {
DbError::Validation(format!("invalid IPv6 in endpoint '{trimmed}': {e}"))
})?;
let rest = &trimmed[closing + 1..];
let port = if let Some(port_str) = rest.strip_prefix(':') {
port_str
.parse::<u16>()
.map_err(|_| DbError::Validation(format!("invalid port in endpoint '{trimmed}'")))?
} else if rest.is_empty() {
51820
} else {
return Err(DbError::Validation(format!(
"invalid endpoint format '{trimmed}'"
)));
};
if port == 0 {
return Err(DbError::Validation("port must be non-zero".into()));
}
return Ok(format!("[{}]:{}", ipv6, port));
}
if let Ok(ipv6) = trimmed.parse::<std::net::Ipv6Addr>() {
return Ok(format!("[{}]:51820", ipv6));
}
if let Some(last_colon) = trimmed.rfind(':') {
let host_part = &trimmed[..last_colon];
let port_part = &trimmed[last_colon + 1..];
if let Ok(port) = port_part.parse::<u16>() {
if port == 0 {
return Err(DbError::Validation("port must be non-zero".into()));
}
let host = nx9_wg_core::validation::validate_server_host(host_part)?;
return Ok(nx9_wg_core::validation::format_endpoint(&host, port));
}
}
let host = nx9_wg_core::validation::validate_server_host(trimmed)?;
Ok(nx9_wg_core::validation::format_endpoint(&host, 51820))
}
+17
View File
@@ -524,6 +524,23 @@ impl Store {
crate::settings::list_settings(&self.pool).await
}
pub async fn get_server_endpoint_settings(
&self,
) -> Result<nx9_wg_core::types::settings::ServerEndpointSettings> {
crate::settings::get_server_endpoint_settings(&self.pool).await
}
pub async fn set_server_endpoint_settings(
&self,
settings: &nx9_wg_core::types::settings::ServerEndpointSettings,
) -> Result<()> {
crate::settings::set_server_endpoint_settings(&self.pool, settings).await
}
pub async fn resolve_server_endpoint(&self, explicit_override: Option<&str>) -> Result<String> {
crate::settings::resolve_server_endpoint(&self.pool, explicit_override).await
}
// Audit
pub async fn create_audit_event(
&self,
@@ -225,3 +225,171 @@ async fn test_backup_metadata_crud() {
.is_none()
);
}
#[tokio::test]
async fn test_server_endpoint_settings_crud_and_persistence() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
// 1. Initial state defaults
let initial = store
.get_server_endpoint_settings()
.await
.expect("get initial");
assert_eq!(initial.host, "");
assert_eq!(initial.port, 51820);
assert!(initial.enabled);
// Initial resolution with no settings returns actionable error
let err = store.resolve_server_endpoint(None).await.unwrap_err();
assert!(
err.to_string()
.contains("No reachable WireGuard server endpoint is configured")
);
// 2. Set structured server endpoint settings
let new_settings = nx9_wg_core::types::settings::ServerEndpointSettings {
host: "vpn.thakares.com".to_string(),
port: 51820,
enabled: true,
};
store
.set_server_endpoint_settings(&new_settings)
.await
.expect("set server endpoint settings");
let loaded = store
.get_server_endpoint_settings()
.await
.expect("get loaded settings");
assert_eq!(loaded.host, "vpn.thakares.com");
assert_eq!(loaded.port, 51820);
assert!(loaded.enabled);
// 3. Resolve persistent setting
let resolved = store.resolve_server_endpoint(None).await.expect("resolve");
assert_eq!(resolved, "vpn.thakares.com:51820");
// 4. IPv6 persistence and formatting
let ipv6_settings = nx9_wg_core::types::settings::ServerEndpointSettings {
host: "2001:db8::10".to_string(),
port: 51821,
enabled: true,
};
store
.set_server_endpoint_settings(&ipv6_settings)
.await
.expect("set ipv6");
let resolved_v6 = store
.resolve_server_endpoint(None)
.await
.expect("resolve v6");
assert_eq!(resolved_v6, "[2001:db8::10]:51821");
// 5. Disable setting
let disabled = nx9_wg_core::types::settings::ServerEndpointSettings {
host: "vpn.thakares.com".to_string(),
port: 51820,
enabled: false,
};
store
.set_server_endpoint_settings(&disabled)
.await
.expect("set disabled");
let err = store.resolve_server_endpoint(None).await.unwrap_err();
assert!(
err.to_string()
.contains("No reachable WireGuard server endpoint is configured")
);
}
#[tokio::test]
async fn test_server_endpoint_resolution_precedence() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
// 1. Explicit override with no settings configured
let ep = store
.resolve_server_endpoint(Some("custom.vpn.net:51820"))
.await
.expect("override");
assert_eq!(ep, "custom.vpn.net:51820");
// 2. Configure persistent settings
let settings = nx9_wg_core::types::settings::ServerEndpointSettings {
host: "persistent.vpn.io".to_string(),
port: 51820,
enabled: true,
};
store
.set_server_endpoint_settings(&settings)
.await
.expect("set");
// Precedence test: explicit override beats persistent setting
let overridden = store
.resolve_server_endpoint(Some("override.vpn.io:5555"))
.await
.expect("override beats persistent");
assert_eq!(overridden, "override.vpn.io:5555");
// Precedence test: None uses persistent setting
let default_resolved = store.resolve_server_endpoint(None).await.expect("default");
assert_eq!(default_resolved, "persistent.vpn.io:51820");
// 3. Legacy fallback test when wireguard.server_host is missing
store
.delete_setting(nx9_wg_core::types::settings::SETTING_SERVER_HOST)
.await
.expect("delete new host");
store
.set_setting("server_endpoint", "legacy.vpn.org:51820", false)
.await
.expect("set legacy");
let legacy_resolved = store
.resolve_server_endpoint(None)
.await
.expect("legacy resolved");
assert_eq!(legacy_resolved, "legacy.vpn.org:51820");
}
#[tokio::test]
async fn test_server_endpoint_survives_reopen() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let db_path = temp_dir.path().join("persist_endpoint.db");
let db_url = format!("sqlite://{}?mode=rwc", db_path.display());
{
let store = Store::connect(&db_url).await.expect("connect 1");
store.migrate().await.expect("migrate 1");
let settings = nx9_wg_core::types::settings::ServerEndpointSettings {
host: "vpn.thakares.com".to_string(),
port: 51820,
enabled: true,
};
store
.set_server_endpoint_settings(&settings)
.await
.expect("set");
}
// Reconnect to existing store on disk
{
let store = Store::connect(&db_url).await.expect("connect 2");
let loaded = store
.get_server_endpoint_settings()
.await
.expect("get after reopen");
assert_eq!(loaded.host, "vpn.thakares.com");
assert_eq!(loaded.port, 51820);
assert!(loaded.enabled);
let resolved = store
.resolve_server_endpoint(None)
.await
.expect("resolve after reopen");
assert_eq!(resolved, "vpn.thakares.com:51820");
}
}
@@ -13,6 +13,8 @@ pub struct ClientExportState {
pub selected_connection: ConnectionType,
pub selected_nat: NatType,
pub manual_mtu_override: Option<u16>,
pub server_endpoint: Option<String>,
pub is_default_from_settings: bool,
pub resolved_profile: Option<ResolvedClientProfile>,
pub is_override_enabled: bool,
pub qr_view_active: bool,
@@ -26,6 +28,8 @@ impl Default for ClientExportState {
selected_connection: ConnectionType::Web,
selected_nat: NatType::Unknown,
manual_mtu_override: None,
server_endpoint: None,
is_default_from_settings: false,
resolved_profile: None,
is_override_enabled: false,
qr_view_active: false,
@@ -59,6 +63,12 @@ impl ClientExportState {
self.selected_provider = provider;
}
/// Set server endpoint override and whether it was populated from default settings.
pub fn set_server_endpoint(&mut self, endpoint: Option<String>, is_default: bool) {
self.server_endpoint = endpoint;
self.is_default_from_settings = is_default;
}
/// Toggle or set manual MTU override.
pub fn set_manual_mtu(&mut self, mtu: Option<u16>) {
self.manual_mtu_override = mtu;
@@ -95,6 +105,14 @@ impl ClientExportState {
if let Some(m) = self.manual_mtu_override {
params.push(format!("mtu={m}"));
}
if let Some(ep) = self
.server_endpoint
.as_deref()
.filter(|s| !s.trim().is_empty())
{
params.push(format!("endpoint={}", urlencoding(ep.trim())));
}
if let Some(id) = profile_id {
params.push(format!("profile={}", urlencoding(id)));
}
@@ -15,6 +15,9 @@ pub struct SettingsState {
pub default_interface: String,
pub default_mtu: u16,
pub default_listen_port: u16,
pub wireguard_server_host: String,
pub wireguard_server_port: u16,
pub wireguard_server_endpoint_enabled: bool,
// Card 3: Networking
pub nat_enabled: bool,
@@ -46,6 +49,9 @@ impl Default for SettingsState {
default_interface: "wg0".to_string(),
default_mtu: 1420,
default_listen_port: 51820,
wireguard_server_host: String::new(),
wireguard_server_port: 51820,
wireguard_server_endpoint_enabled: true,
nat_enabled: true,
ipv4_forwarding: true,
ipv6_forwarding: false,