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

+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");
}
}