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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user