Finalize nx9-wg production release
This commit is contained in:
1 parent
4dfe42fe68
commit
d704c1e131
30 files changed
+2496
-364
No files matched your search
@@ -6,235 +6,389 @@
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
> **Sovereign, self-hosted, Linux-native VPN and network control plane built around the kernel's WireGuard implementation.**
|
> **Sovereign, self-hosted, Linux-native VPN and network control plane built directly around the kernel's WireGuard implementation.**
|
||||||
|
|
||||||
`nx9-wg` is no longer just a WireGuard management wrapper. It has evolved into a **native Linux VPN + networking control plane** built directly around the Linux kernel's WireGuard implementation.
|
`nx9-wg` is a native Linux VPN and networking control plane built directly around the Linux kernel's in-tree WireGuard implementation (`wireguard.ko`).
|
||||||
|
|
||||||
|
Rather than functioning as a user interface wrapper that shells out to external command-line utilities (`wg`, `ip`, `nft`, `sysctl`), `nx9-wg` provides a single self-contained binary architecture. It integrates **authoritative SQLite desired-state persistence**, **direct kernel Netlink execution** (RTNETLINK and WireGuard Generic Netlink), **in-process Netfilter firewall/NAT compilation** (`libnftables.so.1`), **continuous closed-loop reconciliation**, a **multi-format native CLI**, and an **embedded zero-dependency Single Page Application (SPA) Web UI**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Architectural Distinction
|
## 1. The Architectural Paradigm Shift
|
||||||
|
|
||||||
### Typical WireGuard Manager
|
### Typical WireGuard Management Wrappers
|
||||||
```
|
```
|
||||||
UI ──► Configuration Files ──► wg / wg-quick / ip / nft ──► Linux Kernel
|
┌──────────┐ ┌──────────────────────┐ ┌────────────────────────────┐ ┌──────────────┐
|
||||||
|
│ Web UI │ ──► │ Text Config Files │ ──► │ wg / ip / nft / sysctl │ ──► │ Linux Kernel │
|
||||||
|
│ (Node/Py)│ │(/etc/wireguard/*.conf│ │ (Subprocess Spawning) │ │ (wireguard.ko│
|
||||||
|
└──────────┘ └──────────────────────┘ └────────────────────────────┘ └──────────────┘
|
||||||
```
|
```
|
||||||
|
*Disadvantages: Process-spawning overhead, shell escaping risks, lack of transactional state, unmanaged host routing collisions, vulnerability to configuration drift, and heavy runtime footprints.*
|
||||||
|
|
||||||
### Whereas `nx9-wg` is:
|
### Whereas `nx9-wg` is a Native Control & Execution Plane:
|
||||||
```
|
```
|
||||||
nx9-wg
|
┌───────────────────────────────────┐
|
||||||
|
│ nx9-wg │
|
||||||
|
└─────────────────┬─────────────────┘
|
||||||
│
|
│
|
||||||
┌────────────┴────────────┐
|
┌────────────────────────────────┴────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
Desired State Live State
|
┌─────────▼─────────┐ ┌─────────▼─────────┐
|
||||||
|
│ Desired State │ │ Live State │
|
||||||
|
│ (Authoritative) │ │ (Kernel Cache) │
|
||||||
|
└─────────┬─────────┘ └─────────▲─────────┘
|
||||||
│ │
|
│ │
|
||||||
SQLite Linux Kernel
|
┌─────────▼─────────┐ ┌─────────┴─────────┐
|
||||||
│ ▲
|
│ SQLite 3 (WAL) │ │ Linux Kernel │
|
||||||
|
└─────────┬─────────┘ └─────────▲─────────┘
|
||||||
|
│ │
|
||||||
|
│ Live Netlink Telemetry
|
||||||
▼ │
|
▼ │
|
||||||
Reconciliation ◄──────── Telemetry
|
┌───────────────────┐ │
|
||||||
|
│ Reconciliation │ ◄─────────────────────────────────────────────────────┘
|
||||||
|
│ Engine │
|
||||||
|
└─────────┬─────────┘
|
||||||
│
|
│
|
||||||
├── WireGuard Generic Netlink
|
├── 🔐 WireGuard Generic Netlink (family "wireguard")
|
||||||
├── RTNETLINK
|
├── 🌐 RTNETLINK (Links, IPv4/IPv6 Addresses, Routes)
|
||||||
├── Netfilter / libnftables
|
├── 🔥 Netfilter / libnftables FFI (table inet nx9_wg)
|
||||||
└── procfs
|
└── ↔️ Direct Procfs IP Forwarding (/proc/sys/net)
|
||||||
│
|
|
||||||
▼
|
|
||||||
Linux networking
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Capabilities Beyond Interface Creation
|
## 2. Key Capabilities
|
||||||
|
|
||||||
- 🔐 **WireGuard interface and peer lifecycle** (RTNETLINK + WireGuard Generic Netlink)
|
- **WireGuard Interface & Peer Lifecycle**: Direct RTNETLINK link management (`RTM_NEWLINK`/`RTM_DELLINK`) and WireGuard Generic Netlink (`WG_CMD_SET_DEVICE`/`WG_CMD_GET_DEVICE`) with cryptokey routing.
|
||||||
- 🌐 **IPv4/IPv6 address management** (In-process `RTM_NEWADDR` / `RTM_DELADDR`)
|
- **Persistent Server Endpoint Settings**: Authoritative configuration of public client-reachable endpoint (`wireguard.server_host`, `wireguard.server_port`, `wireguard.server_endpoint_enabled`) automatically embedded into client exports and QR codes.
|
||||||
- 🛣️ **Route management & protected route reconciliation** (Zero default-route interference)
|
- **Strict AllowedIPs Semantic Separation**: Correctly derives server-side cryptokey routing AllowedIPs (`/32` and `/128`) from assigned tunnel addresses, distinct from client full-tunnel (`0.0.0.0/0, ::/0`) routing policies.
|
||||||
- 🔥 **Firewall rule management** (In-process `libnftables.so.1` FFI in `table inet nx9_wg`)
|
- **IPv4/IPv6 Address Management**: In-process `RTM_NEWADDR` and `RTM_DELADDR` Netlink execution without invoking `ip addr`.
|
||||||
- 🛡️ **Scoped NAT/masquerading** (Strictly scoped to managed WireGuard client subnets)
|
- **Protected Route Management**: In-process routing table reconciliation protecting host default routes from accidental disruption.
|
||||||
- ↔️ **IPv4/IPv6 forwarding** (Direct atomic `/proc/sys/net` sysctl control)
|
- **In-Process nftables Firewall & NAT**: Transactional rule compilation via `libnftables.so.1` strictly scoped to `table inet nx9_wg`.
|
||||||
- 📡 **Live WireGuard telemetry** (Handshake timestamps, authenticated roaming endpoints, byte counters)
|
- **Scoped Outbound NAT Masquerade**: Automated masquerading scoped to managed WireGuard client subnets and non-WireGuard egress interfaces.
|
||||||
- 🔄 **Desired-state reconciliation** (Continuous closed-loop convergence)
|
- **Atomic IP Packet Forwarding**: Direct `/proc/sys/net/ipv4/ip_forward` and IPv6 forwarding control.
|
||||||
- 🧭 **Drift detection** (5-subsystem read-only deterministic planning)
|
- **Live Kernel Telemetry**: Live handshake timestamps, authenticated roaming endpoints, and 64-bit RX/TX byte counters merged into API and WebUI responses.
|
||||||
- ♻️ **Restart recovery** (Cold-boot reconstruction of live kernel networking from SQLite)
|
- **Closed-Loop Reconciliation**: Continuous drift detection, dry-run deterministic planning, and serialized convergence.
|
||||||
- 🧪 **Simulation engine** (Full macOS/Windows local development fallback)
|
- **Cold-Boot Restart Recovery**: Deterministic reconstruction of live kernel networking from authoritative SQLite state upon boot.
|
||||||
- 🖥️ **CLI + REST API + WebSocket + SPA** (Zero-dependency embedded interface)
|
- **Single Administrator Identity**: Database-level `CHECK (id = 1)` constraint, Argon2id password hashing, and SHA-256 API token digests.
|
||||||
- 📊 **Diagnostics** (Automated health inspection across 9 subsystems with remediation hints)
|
- **Zero-Dependency Single Page Application (SPA)**: Embedded HTML5/CSS/JS frontend with dark/light themes, live WebSocket telemetry, and responsive mobile-first UI.
|
||||||
- 💾 **Backup/restore** (Atomic online `VACUUM INTO` snapshots with SHA-256 manifests)
|
- **Pure Rust Client Configuration & QR**: In-process generation of standard `.conf` text and SVG, PNG, and terminal ASCII QR codes.
|
||||||
- 🔑 **Administrator/authentication/API tokens** (Argon2id, SHA-256 tokens, single-admin `CHECK (id=1)`)
|
- **Automated Health Diagnostics**: Deep inspection across 11 subsystems with actionable remediation hints.
|
||||||
- 📱 **Client profiles + generated configurations + QR** (Pure Rust SVG, PNG, and ASCII QR engine)
|
- **Atomic SQLite Online Backups**: Non-blocking `VACUUM INTO` snapshots with SHA-256 integrity manifests and pre-restore safety snapshots.
|
||||||
- 📦 **Standalone Linux deployment** (Zero scripting runtime, self-contained distribution packages)
|
- **Application Subprocess Isolation**: The `nx9-wg` Rust application does not invoke `wg`, `ip`, `nft`, `sysctl`, or shell commands through `std::process::Command`. Operational administration scripts may use standard Linux utilities for deployment, diagnostics, backup, and service management.
|
||||||
- 🔒 **systemd capability isolation** (`CAP_NET_ADMIN`, `CAP_NET_BIND_SERVICE`, full sandbox directives)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SQLite as the Single Authority
|
## 3. Core Design Principles
|
||||||
|
|
||||||
The foundational architectural choice in `nx9-wg` is **SQLite as the authoritative desired state**.
|
| Principle | Technical Implementation |
|
||||||
|
| :--- | :--- |
|
||||||
WireGuard is not the configuration database. The kernel is not the configuration database either.
|
| **Operator Sovereignty** | 100% self-hosted local execution. Private keys, configuration data, and cryptographic credentials never leave the host. |
|
||||||
|
| **SQLite Authority** | SQLite in WAL mode is the single authoritative source of truth. Kernel state is reconciled to match the database. |
|
||||||
```
|
| **Zero Application Subprocesses** | All kernel interactions performed by the Rust application execute through native Linux Netlink sockets, `libnftables.so.1` FFI, and direct procfs writes. Operational shell scripts are separate administrative tooling. |
|
||||||
SQLite
|
| **Defense in Depth** | Single administrator model (`CHECK (id=1)`), Argon2id hashing, SHA-256 token digests, HttpOnly `nx9_session` cookies, brute-force rate limiting, and strict secret redaction. |
|
||||||
│
|
| **Simplicity & Reliability** | Single binary, single database file, single configuration file, self-contained systemd service, and zero external runtime dependencies. |
|
||||||
│ desired state
|
|
||||||
▼
|
|
||||||
nx9-wg reconciler
|
|
||||||
│
|
|
||||||
│ convergence
|
|
||||||
▼
|
|
||||||
Linux kernel
|
|
||||||
```
|
|
||||||
|
|
||||||
If the kernel state disappears after a reboot or network interface reset, the system deterministically reconstructs the entire network topology from the authoritative desired state in SQLite.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The NX9 Philosophy in Implementation
|
## 4. Workspace Architecture
|
||||||
|
|
||||||
`nx9-wg` deliberately avoids building an application around a fragile pile of external utilities:
|
`nx9-wg` is structured as a modular six-crate Rust workspace:
|
||||||
|
|
||||||
- ❌ `wg`
|
- [**`crates/nx9-wg-core`**](crates/nx9-wg-core): Typed domain models (`Interface`, `Peer`, `Network`, `Route`, `FirewallRule`, `Setting`, `ServerEndpointSettings`, `Admin`), RFC-compliant validators, cryptography (Argon2id, SHA-256, X25519), and hierarchical configuration loader.
|
||||||
- ❌ `wg-quick`
|
- [**`crates/nx9-wg-db`**](crates/nx9-wg-db): Authoritative SQLite store, 13 relational tables, automated migrations via `sqlx`, and repository implementations in WAL mode.
|
||||||
- ❌ `ip`
|
- [**`crates/nx9-wireguard`**](crates/nx9-wireguard): WireGuard Generic Netlink execution, RTNETLINK link management, client `.conf` generator, and pure Rust QR engine (SVG, PNG, ASCII).
|
||||||
- ❌ `iptables`
|
- [**`crates/nx9-wg-network`**](crates/nx9-wg-network): RTNETLINK routing engine, `libnftables.so.1` Netfilter integration in `table inet nx9_wg`, and direct procfs packet forwarding.
|
||||||
- ❌ `nft` CLI
|
- [**`crates/nx9-wg-api`**](crates/nx9-wg-api): Axum REST router, WebSocket real-time broadcaster, session/token authentication, deterministic IP allocator, and reconciliation engine.
|
||||||
- ❌ `sysctl` CLI
|
- [**`crates/nx9-wg-ui`**](crates/nx9-wg-ui): Pure CSS design system, responsive stylesheet compiler, view models, and embedded SPA assets.
|
||||||
- ❌ shell orchestration
|
- **`src/main.rs`**: Root executable CLI providing full subcommand coverage and daemon orchestration.
|
||||||
- ❌ Node.js runtime
|
|
||||||
- ❌ Python runtime
|
|
||||||
|
|
||||||
Instead, all kernel operations are executed directly from native Rust:
|
|
||||||
|
|
||||||
```
|
|
||||||
Rust
|
|
||||||
│
|
|
||||||
├── Generic Netlink ──► WireGuard (wireguard.ko)
|
|
||||||
├── RTNETLINK ──► Interfaces / Routes / Addresses
|
|
||||||
├── Netfilter ──► Firewall / NAT (libnftables FFI)
|
|
||||||
└── procfs ──► Packet Forwarding (/proc/sys/net)
|
|
||||||
```
|
|
||||||
|
|
||||||
That is why **"native Linux VPN + networking platform"** is the accurate description for `nx9-wg`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Status
|
## 5. Persistent WireGuard Server Endpoint Configuration
|
||||||
|
|
||||||
| Subsystem | Status | Verification Evidence |
|
When generating client configuration files (`.conf`) and QR codes, `nx9-wg` automatically embeds the public or reachable server endpoint address where WireGuard clients connect across the Internet or WAN.
|
||||||
| :--- | :---: | :--- |
|
|
||||||
| **System Architecture** | **Complete** | Six-crate modular workspace with strict layer boundaries |
|
### Setting Keys in SQLite
|
||||||
| **Control Plane & REST API** | **Complete** | Axum HTTP server with session/bearer auth and WebSocket stream |
|
|
||||||
| **Native WireGuard Engine** | **Implemented** | RTNETLINK link lifecycle & Generic Netlink cryptokey exchange |
|
| Key | Type | Description | Default | Example |
|
||||||
| **Native Linux Networking** | **Implemented** | RTNETLINK address/route management & direct procfs forwarding |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| **Native nftables / NAT** | **Implemented** | In-process `libnftables` FFI scoped to `table inet nx9_wg` |
|
| `wireguard.server_host` | String | Public/reachable server hostname or IP address (DNS hostname, IPv4, or IPv6). Must not contain a port. | Empty | `vpn.thakares.com` or `203.0.113.10` or `2001:db8::10` |
|
||||||
| **Reconciliation Engine** | **Implemented** | Closed-loop drift detection, read-only plan, and serialized apply |
|
| `wireguard.server_port` | u16 | Public reachable UDP port where clients connect (`1..=65535`). | `51820` | `51820` |
|
||||||
| **Web User Interface** | **Verified** | Zero-dependency SPA with theme engine and 15 interactive routes |
|
| `wireguard.server_endpoint_enabled` | Boolean | Whether the persistent endpoint is used as the default for client exports. | `true` | `true` |
|
||||||
| **Release & Deployment** | **Implemented** | Standalone installer, uninstaller, packaging script, and systemd unit |
|
| `server_endpoint` | String | Legacy formatted endpoint fallback (`host:port` or `[ipv6]:port`). | Empty | `vpn.thakares.com:51820` |
|
||||||
| **SAFE Verification Suite** | **PASS** | 162 workspace tests, 203 CLI tests, 19 integration tests, 23 live tests |
|
| `public_endpoint` | String | Legacy secondary fallback. | Empty | `vpn.thakares.com:51820` |
|
||||||
| **LIVE Kernel Verification** | **Framework Ready** | SAFE mode (`LIVE=0`) verified; dedicated host ready via `LIVE=1` |
|
|
||||||
|
### Authoritative Resolution Precedence
|
||||||
|
|
||||||
|
1. **Explicit per-request / per-export override**: Passed via `--endpoint <ENDPOINT>` in the CLI or `?endpoint=<ENDPOINT>` in the REST API.
|
||||||
|
2. **Persistent structured settings**: `wireguard.server_host` + `wireguard.server_port` when `wireguard.server_endpoint_enabled` is `true` and `host` is non-empty.
|
||||||
|
3. **Legacy `server_endpoint` setting**: If present and non-empty.
|
||||||
|
4. **Legacy `public_endpoint` setting**: If present and non-empty.
|
||||||
|
5. **Actionable configuration error**: Directs the administrator to configure the server endpoint in Settings or provide `--endpoint`.
|
||||||
|
|
||||||
|
> **Separation Invariant**: The public server endpoint port (`wireguard.server_port`) is conceptually separate from the local WireGuard kernel interface UDP `listen_port` (which may bind locally behind NAT, port-forwarding, or intermediate firewalls).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Six-Crate Workspace Architecture
|
|
||||||
|
|
||||||
- [**`crates/nx9-wg-core`**](crates/nx9-wg-core): Typed domain models, validation, cryptography, and hierarchical configuration loader.
|
|
||||||
- [**`crates/nx9-wg-db`**](crates/nx9-wg-db): Authoritative SQLite store, migration engine, and isolated repositories in WAL mode.
|
|
||||||
- [**`crates/nx9-wireguard`**](crates/nx9-wireguard): WireGuard Generic Netlink execution, RTNETLINK link management, `.conf` builder, and pure Rust QR engine.
|
|
||||||
- [**`crates/nx9-wg-network`**](crates/nx9-wg-network): RTNETLINK routing engine, `libnftables.so.1` Netfilter integration, and procfs forwarding.
|
|
||||||
- [**`crates/nx9-wg-api`**](crates/nx9-wg-api): Axum REST router, WebSocket broadcaster, authentication, and reconciliation engine.
|
|
||||||
- [**`crates/nx9-wg-ui`**](crates/nx9-wg-ui): Pure CSS design system, responsive stylesheet generator, and view models.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## 6. Installation & Deployment
|
||||||
|
|
||||||
### 1. Build and Run Workspace Tests
|
### Quick Start (Pre-Built Archive)
|
||||||
```bash
|
|
||||||
# Build the release binary
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# Run the complete test suite (162/162 passed)
|
|
||||||
cargo test --workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Initialize Administrator Account
|
|
||||||
```bash
|
|
||||||
# Generate a cryptographically secure random password written to a restricted file:
|
|
||||||
./target/release/nx9-wg init --generate-password --write-password-file /tmp/admin.pw
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Start the API Daemon & Web UI
|
|
||||||
```bash
|
|
||||||
./target/release/nx9-wg serve --bind 127.0.0.1:8080
|
|
||||||
```
|
|
||||||
Open your browser at `http://127.0.0.1:8080/` to access the Web UI.
|
|
||||||
|
|
||||||
### 4. Interface Creation & Peer Enrollment via CLI
|
|
||||||
```bash
|
|
||||||
# Create WireGuard interface wg0
|
|
||||||
./target/release/nx9-wg interface create --address-v4 10.100.0.1/24 wg0 --port 51820
|
|
||||||
|
|
||||||
# Enroll peer Alice with automatic IP allocation and mobile MTU profile:
|
|
||||||
./target/release/nx9-wg peer create --interface wg0 --name alice --profile full_tunnel --mtu 1280
|
|
||||||
|
|
||||||
# Render ASCII QR code in terminal for mobile scanning:
|
|
||||||
./target/release/nx9-wg peer qr <PEER_UUID>
|
|
||||||
|
|
||||||
# Export WireGuard client configuration file:
|
|
||||||
./target/release/nx9-wg peer config <PEER_UUID>
|
|
||||||
|
|
||||||
# Apply reconciliation to synchronize kernel state:
|
|
||||||
./target/release/nx9-wg reconcile apply
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Production Installation
|
|
||||||
|
|
||||||
To install `nx9-wg` as a managed systemd service:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download and extract release archive:
|
# Extract release archive:
|
||||||
tar -xzf nx9-wg-v1.0.0-linux-x86_64.tar.gz
|
tar -xzf nx9-wg-v1.0.0-linux-x86_64.tar.gz
|
||||||
cd nx9-wg-v1.0.0-linux-x86_64
|
cd nx9-wg-v1.0.0-linux-x86_64
|
||||||
|
|
||||||
# Run production installer:
|
# Run production installer as root:
|
||||||
sudo bash install.sh
|
sudo bash install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [**Installation & Deployment Guide**](docs/installation.md) for step-by-step instructions.
|
### Production Deployment from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build optimized release binary and run quality gates:
|
||||||
|
bash scripts/build-release.sh
|
||||||
|
|
||||||
|
# 2. Deploy the validated release binary to /usr/local/bin/nx9-wg with automatic backup and service restart:
|
||||||
|
sudo bash scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Installation from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build optimized release binary:
|
||||||
|
cargo build --release --workspace
|
||||||
|
|
||||||
|
# 2. Install binary:
|
||||||
|
sudo install -m 0755 target/release/nx9-wg /usr/local/bin/nx9-wg
|
||||||
|
|
||||||
|
# 3. Create required directories with strict permissions:
|
||||||
|
sudo install -d -m 0750 /etc/nx9-wg
|
||||||
|
sudo install -d -m 0700 /var/lib/nx9-wg
|
||||||
|
sudo install -d -m 0700 /var/lib/nx9-wg/backups
|
||||||
|
sudo install -d -m 0750 /var/log/nx9-wg
|
||||||
|
|
||||||
|
# 4. Bootstrap administrator account:
|
||||||
|
sudo /usr/local/bin/nx9-wg init --generate-password --write-password-file /var/lib/nx9-wg/admin-password
|
||||||
|
sudo chmod 0600 /var/lib/nx9-wg/admin-password
|
||||||
|
|
||||||
|
# 5. Start the daemon:
|
||||||
|
sudo /usr/local/bin/nx9-wg serve --bind 0.0.0.0:8080
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Complete Documentation Index
|
## 7. Configuration Reference
|
||||||
|
|
||||||
| Topic | Documentation Link |
|
Configuration is evaluated hierarchically: **CLI Arguments** > **Environment Variables** > **TOML Configuration File** > **Compiled Defaults**.
|
||||||
|
|
||||||
|
### TOML Format (`/etc/nx9-wg/config.toml`)
|
||||||
|
```toml
|
||||||
|
data_dir = "/var/lib/nx9-wg"
|
||||||
|
bind_address = "0.0.0.0:8080"
|
||||||
|
log_level = "info"
|
||||||
|
session_expiry_hours = 24
|
||||||
|
reconciliation_interval_secs = 60
|
||||||
|
|
||||||
|
[backup]
|
||||||
|
dir = "/var/lib/nx9-wg/backups"
|
||||||
|
max_count = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- `NX9_WG_CONFIG`: Path to configuration file.
|
||||||
|
- `NX9_WG_DATA_DIR`: Path to persistent data directory.
|
||||||
|
- `NX9_WG_DATABASE`: Explicit SQLite database file path.
|
||||||
|
- `NX9_WG_LISTEN_ADDR`: Daemon bind address.
|
||||||
|
- `NX9_WG_LOG_LEVEL`: Log verbosity filter (`trace`, `debug`, `info`, `warn`, `error`).
|
||||||
|
- `NX9_WG_ADMIN_USERNAME` / `NX9_WG_ADMIN_PASSWORD` / `NX9_WG_ADMIN_PASSWORD_FILE`: Bootstrap administrator credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Web User Interface (SPA)
|
||||||
|
|
||||||
|
Access the Web UI at `http://<server-ip>:8080/`. The interface is a zero-dependency SPA embedded inside the binary:
|
||||||
|
|
||||||
|
- **Dashboard (`#dashboard`)**: System status, uptime, interface/peer counts, diagnostics health summary, and live reconciliation status.
|
||||||
|
- **Interfaces (`#interfaces`)**: Interface list, "+ Create Interface" modal, interface **Edit** action (preserves private/public key identity), enable/disable toggle, and delete action.
|
||||||
|
- **Peers (`#peers`)**: Enrolled peer table with real-time handshakes, status filters, "+ Add Peer" modal with MTU profile resolution, client configuration export modal, and live SVG QR rendering.
|
||||||
|
- **Networks (`#networks`)**: Subnet network definitions, CIDR blocks, available unallocated IP inspection, and "+ Create Network" modal.
|
||||||
|
- **Routes (`#routes`)**: Kernel routing table entries, gateway assignments, and "+ Create Route" modal.
|
||||||
|
- **Firewall (`#firewall`)**: Packet filtering rules in `table inet nx9_wg`, priority ordering, and "+ Create Rule" modal.
|
||||||
|
- **NAT & Masquerade (`#nat`)**: Outbound NAT masquerade status card, active masqueraded subnet list, and instant toggle.
|
||||||
|
- **IP Forwarding (`#forwarding`)**: Kernel `/proc/sys/net/ipv4/ip_forward` status and toggle.
|
||||||
|
- **Reconciliation (`#reconciliation`)**: Real-time drift overview, planned execution actions table, and "Run Reconcile (Apply)" trigger.
|
||||||
|
- **Diagnostics (`#diagnostics`)**: Automated health inspection across 11 subsystems with remediation hints.
|
||||||
|
- **Live State (`#live-state`)**: Real-time Netlink kernel telemetry, active WireGuard links, and kernel routing table.
|
||||||
|
- **Settings (`#settings`)**: Dedicated **WireGuard Server Endpoint** configuration card (Host, Port, Enabled toggle, live preview, save), appliance parameters table, and danger zone reset controls.
|
||||||
|
- **Backups (`#backups`)**: Atomic SQLite backup snapshots list, "+ Create Backup Snapshot" trigger, and direct `.db` download.
|
||||||
|
- **Audit Log (`#audit`)**: Append-only security and administrative audit trail with actor, IP, timestamp, and context metadata.
|
||||||
|
- **Administrator (`#administrator`)**: Admin account verification, password update modal, and "+ Generate API Token" modal with one-time raw secret copy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Native CLI Command Reference
|
||||||
|
|
||||||
|
The `nx9-wg` binary provides native CLI coverage across all 18 command groups:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. System Settings & Server Endpoint
|
||||||
|
nx9-wg system settings set wireguard.server_host vpn.thakares.com
|
||||||
|
nx9-wg system settings set wireguard.server_port 51820
|
||||||
|
nx9-wg system settings set wireguard.server_endpoint_enabled true
|
||||||
|
|
||||||
|
# 2. Interface Creation & Editing
|
||||||
|
nx9-wg interface create wg0 --address-v4 10.100.0.1/24 --port 51820 --mtu 1420
|
||||||
|
nx9-wg interface update wg0 --mtu 1420
|
||||||
|
|
||||||
|
# 3. Peer Enrollment & Client Config Export
|
||||||
|
nx9-wg peer create --interface wg0 --name alice-phone --profile full_tunnel --mtu 1280
|
||||||
|
|
||||||
|
# Export client configuration (uses persistent server endpoint):
|
||||||
|
nx9-wg peer config <PEER_UUID>
|
||||||
|
|
||||||
|
# Export with explicit one-off override:
|
||||||
|
nx9-wg peer config <PEER_UUID> --endpoint custom.backup-vpn.com:51820
|
||||||
|
|
||||||
|
# Render ASCII QR code in terminal for mobile scanning:
|
||||||
|
nx9-wg peer qr <PEER_UUID>
|
||||||
|
|
||||||
|
# Render QR code as SVG:
|
||||||
|
nx9-wg peer qr <PEER_UUID> --qr-format svg
|
||||||
|
|
||||||
|
# 4. Reconciliation
|
||||||
|
nx9-wg reconcile plan
|
||||||
|
nx9-wg reconcile apply
|
||||||
|
nx9-wg reconcile verify
|
||||||
|
|
||||||
|
# 5. Live Telemetry & Diagnostics
|
||||||
|
nx9-wg live peer wg0
|
||||||
|
nx9-wg diagnostics all
|
||||||
|
|
||||||
|
# 6. Database Backups
|
||||||
|
nx9-wg backup create --description "Pre-maintenance snapshot"
|
||||||
|
nx9-wg backup list
|
||||||
|
nx9-wg backup verify /var/lib/nx9-wg/backups/snapshot.db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Peer Creation & Cryptokey Routing Semantics
|
||||||
|
|
||||||
|
`nx9-wg` strictly enforces the architectural separation between server-side cryptokey routing and client-side routing policy:
|
||||||
|
|
||||||
|
### Server-Side Cryptokey Routing (`AllowedIPs`)
|
||||||
|
For a road-warrior peer assigned address `10.100.0.2/32`, the server kernel's WireGuard peer configuration receives:
|
||||||
|
```ini
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <CLIENT_PUBLIC_KEY>
|
||||||
|
AllowedIPs = 10.100.0.2/32
|
||||||
|
```
|
||||||
|
*This ensures the Linux kernel cryptographically binds packet transmission and reception specifically to `10.100.0.2/32` and prevents peer routing collisions.*
|
||||||
|
|
||||||
|
### Generated Client Configuration (`.conf`)
|
||||||
|
The generated client configuration file exported for the client device contains:
|
||||||
|
```ini
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = <CLIENT_PRIVATE_KEY>
|
||||||
|
Address = 10.100.0.2/32
|
||||||
|
DNS = 1.1.1.1, 1.0.0.1
|
||||||
|
MTU = 1280
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <SERVER_PUBLIC_KEY>
|
||||||
|
Endpoint = vpn.thakares.com:51820
|
||||||
|
AllowedIPs = 0.0.0.0/0, ::/0
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Authentication, Sessions & WebSockets
|
||||||
|
|
||||||
|
- **Single Administrator Identity**: Locked with `CHECK (id = 1)`. Passwords derived using memory-hard Argon2id.
|
||||||
|
- **Session Security**: Authenticated browser sessions receive an `HttpOnly`, `SameSite=Strict`, `Path=/` cookie named `nx9_session`.
|
||||||
|
- **API Tokens**: Cryptographically hashed tokens (`nx9_<uuid>_<random>`). Only the SHA-256 digest is stored in SQLite.
|
||||||
|
- **Brute-Force Rate Limiting**: Exponential backoff and IP-based rate limiting (5 failed attempts per 15-minute sliding window triggers `HTTP 429 Too Many Requests`).
|
||||||
|
- **Real-Time WebSocket Protocol (`/api/v1/ws`)**: Authenticates seamlessly using the browser's `nx9_session` HttpOnly cookie or `Authorization: Bearer <token>` header, streaming real-time events: `AuditEvent`, `InterfaceChanged`, `PeerChanged`, `PeerHandshake`, and `SettingsChanged`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Quality Assurance & Verification Evidence
|
||||||
|
|
||||||
|
All release quality gates have been executed and verified on Debian Linux:
|
||||||
|
|
||||||
|
| Quality Gate | Command | Result |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Formatting** | `cargo fmt --all -- --check` | **PASS** (0 errors) |
|
||||||
|
| **Compilation** | `cargo check --workspace --all-targets` | **PASS** (0 errors) |
|
||||||
|
| **Clippy Linting** | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | **PASS** (0 warnings) |
|
||||||
|
| **Workspace Test Suite** | `cargo test --workspace --all-targets` | **PASS** (All 88 tests passing) |
|
||||||
|
| **CLI Test Suite** | `cargo test --test test_cli_commands` | **PASS** (11 tests passing) |
|
||||||
|
| **Release Compilation** | `cargo build --release --workspace` | **PASS** (Optimized release binary) |
|
||||||
|
| **Production Server Acceptance** | Physical Android WireGuard client connection | **VERIFIED** (Live handshake and RX/TX telemetry confirmed) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Operational Tooling & Lifecycle Scripts
|
||||||
|
|
||||||
|
The repository includes a curated set of production-grade operational helper scripts in [`scripts/`](scripts/):
|
||||||
|
|
||||||
|
| Script | Purpose & Key Operations |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| **Philosophy & Intent** | [**NX9 Design Principles**](docs/design-principles.md) |
|
| [`scripts/verify.sh`](scripts/verify.sh) | **Development Verification**: Executes formatting check, workspace check, Clippy with `-D warnings`, full test suite, and comprehensive CLI verification. |
|
||||||
| **System Architecture** | [**Architecture Reference**](docs/architecture.md) |
|
| [`scripts/build-release.sh`](scripts/build-release.sh) | **Release Compilation**: Executes all quality gates and compiles an optimized binary to `target/release/nx9-wg` without invoking `cargo clean`. |
|
||||||
| **Installation & Setup** | [**Installation Guide**](docs/installation.md) |
|
| [`scripts/package-release.sh`](scripts/package-release.sh) | **Release Packaging**: Bundles binary, systemd unit, default configuration, docs, and installer into `.tar.gz` and `.tar.xz` release archives. |
|
||||||
| **Platform Requirements** | [**Linux Requirements**](docs/linux_requirements.md) |
|
| [`scripts/deploy.sh`](scripts/deploy.sh) | **Production Deployment**: Validates the source release binary, backs up the active binary to `/usr/local/bin/nx9-wg.backup.<timestamp>`, performs a controlled replacement, checks UDP socket conflicts safely, updates the systemd unit, restarts the service, and verifies the active version. |
|
||||||
| **Native WireGuard** | [**Native WireGuard Engine**](docs/native-wireguard.md) |
|
| [`scripts/rollback.sh`](scripts/rollback.sh) | **Production Rollback**: Automatically discovers deployment backups in `/usr/local/bin/nx9-wg.backup.*` and safely rolls back the active binary with service verification. |
|
||||||
| **Native Networking** | [**Native Network & Routing Engine**](docs/native-network.md) |
|
| [`scripts/diagnose.sh`](scripts/diagnose.sh) | **Production Diagnostics**: Collects a secret-safe snapshot of service health, journal logs, active UDP socket listeners, kernel WireGuard state (`wg show`), nftables rules, sysctl IP forwarding, and appliance diagnostics. |
|
||||||
| **nftables & NAT** | [**Native nftables Engine**](docs/nftables.md) • [**Firewall/NAT Model**](docs/firewall_nat.md) |
|
| [`scripts/backup-source.sh`](scripts/backup-source.sh) | **Source Archive Backup**: Creates a timestamped `.tar.xz` source snapshot excluding `target/` and temporary databases while preserving `.git/` history. |
|
||||||
| **State Reconciliation** | [**Reconciliation & Convergence**](docs/reconciliation.md) |
|
| [`scripts/install.sh`](scripts/install.sh) | **Host Installer**: Bootstraps directories, config template, initial administrator, and systemd service unit. |
|
||||||
| **Web User Interface** | [**Web UI & SPA Routes**](docs/ui.md) |
|
| [`scripts/uninstall.sh`](scripts/uninstall.sh) | **Host Uninstaller**: Safely removes binary and service unit while preserving configuration and SQLite database by default (`--purge` for teardown). |
|
||||||
| **REST API & WebSockets** | [**API Reference**](docs/api.md) |
|
|
||||||
| **CLI Commands** | [**CLI Command Reference**](docs/cli.md) |
|
|
||||||
| **Security Architecture** | [**Security Model & Permissions**](docs/security.md) |
|
|
||||||
| **Backup & Recovery** | [**Backup & Disaster Recovery**](docs/backup_restore.md) |
|
|
||||||
| **Release Engineering** | [**Release Packaging & Systemd**](docs/release.md) |
|
|
||||||
| **Quality Assurance** | [**Comprehensive Testing Specification**](docs/TESTING.md) |
|
|
||||||
| **Developer Guide** | [**Development Guide**](docs/development.md) |
|
|
||||||
| **Configuration** | [**Configuration Reference**](docs/configuration.md) |
|
|
||||||
| **Containerization** | [**Docker Deployment**](docs/docker.md) |
|
|
||||||
| **Master Index** | [**Documentation Master Index**](docs/README.md) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## 14. Security Considerations
|
||||||
|
|
||||||
|
- **Application Subprocess Isolation**: `nx9-wg` does not spawn external shell commands or utilities from the Rust application, eliminating command-injection and shell-escaping exposure from application-level command execution. Operational scripts are separate administrative tooling.
|
||||||
|
- **Firewall Scoping**: All nftables operations are confined to `table inet nx9_wg`. External tables from Docker, Kubernetes, or host firewalls are completely untouched.
|
||||||
|
- **Secret Redaction**: Private keys, preshared keys, password hashes, and token digests are masked (`[REDACTED]`) in `Debug` formatters, CLI outputs, and API responses.
|
||||||
|
- **Strict File Permissions**: The data directory and SQLite database are locked to `0700` and `0600` (`root:root`).
|
||||||
|
- **Client Configuration Protection**: Exported `.conf` files and QR codes contain sensitive client private keys and must be delivered securely to client devices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. License
|
||||||
|
|
||||||
Dual-licensed under either:
|
Dual-licensed under either:
|
||||||
- **MIT License** ([`LICENSE-MIT`](LICENSE-MIT))
|
- **MIT License** ([`LICENSE-MIT`](LICENSE-MIT))
|
||||||
- **Apache License, Version 2.0** ([`LICENSE-APACHE`](LICENSE-APACHE))
|
- **Apache License, Version 2.0** ([`LICENSE-APACHE`](LICENSE-APACHE))
|
||||||
|
|
||||||
at your option.
|
at your option.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Documentation Master Index
|
||||||
|
|
||||||
|
For detailed subsystem documentation, see the [**Documentation Index**](docs/README.md):
|
||||||
|
- [NX9 Design Principles](docs/design-principles.md)
|
||||||
|
- [System Architecture](docs/architecture.md)
|
||||||
|
- [Installation Guide](docs/installation.md)
|
||||||
|
- [Native WireGuard Engine](docs/native-wireguard.md)
|
||||||
|
- [Native Network Engine](docs/native-network.md)
|
||||||
|
- [Native nftables Engine](docs/nftables.md)
|
||||||
|
- [Firewall & NAT Model](docs/firewall_nat.md)
|
||||||
|
- [Reconciliation & Convergence](docs/reconciliation.md)
|
||||||
|
- [Web User Interface Reference](docs/ui.md)
|
||||||
|
- [REST API & WebSocket Reference](docs/api.md)
|
||||||
|
- [CLI Command Reference](docs/cli.md)
|
||||||
|
- [Configuration Reference](docs/configuration.md)
|
||||||
|
- [Security & Privilege Architecture](docs/security.md)
|
||||||
|
- [Backup & Disaster Recovery](docs/backup_restore.md)
|
||||||
|
- [Release Engineering](docs/release.md)
|
||||||
|
- [Testing Specification](docs/TESTING.md)
|
||||||
|
- [Development Guide](docs/development.md)
|
||||||
|
- [Docker Deployment](docs/docker.md)
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions" style="display: flex; gap: 8px;">
|
<div class="page-actions" style="display: flex; gap: 8px;">
|
||||||
<button class="btn btn-secondary" onclick="renderPage('peers')">↻ Refresh Telemetry</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -717,12 +717,32 @@
|
|||||||
// ── Modals: Client Export & QR ─────────────────────────────────────────────
|
// ── Modals: Client Export & QR ─────────────────────────────────────────────
|
||||||
window.openClientExportModal = async function(peerId) {
|
window.openClientExportModal = async function(peerId) {
|
||||||
let defaultEndpoint = '';
|
let defaultEndpoint = '';
|
||||||
|
let isDefaultFromSettings = false;
|
||||||
try {
|
try {
|
||||||
const settings = await api('/system/settings');
|
const settings = await api('/system/settings');
|
||||||
if (Array.isArray(settings)) {
|
if (Array.isArray(settings)) {
|
||||||
|
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 srvEp = settings.find(s => s.key === 'server_endpoint')?.value;
|
||||||
const pubEp = settings.find(s => s.key === 'public_endpoint')?.value;
|
const pubEp = settings.find(s => s.key === 'public_endpoint')?.value;
|
||||||
defaultEndpoint = (srvEp || pubEp || '').trim();
|
const legacy = (srvEp || pubEp || '').trim();
|
||||||
|
if (legacy) {
|
||||||
|
defaultEndpoint = legacy;
|
||||||
|
isDefaultFromSettings = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
@@ -758,8 +778,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top: 10px;">
|
<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>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||||
<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}')" />
|
<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>
|
||||||
</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="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 style="font-weight: 600; margin-bottom: 4px;">⚠️ QR Export Notice</div>
|
||||||
<div>${escapeHtml(errMsg)}</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -854,6 +880,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
window.switchExportTab = function(tab) {
|
window.switchExportTab = function(tab) {
|
||||||
const qrView = document.getElementById('export-qr-view');
|
const qrView = document.getElementById('export-qr-view');
|
||||||
const confView = document.getElementById('export-conf-view');
|
const confView = document.getElementById('export-conf-view');
|
||||||
@@ -1921,28 +1948,86 @@
|
|||||||
const settings = await api('/system/settings') || [];
|
const settings = await api('/system/settings') || [];
|
||||||
const settingList = Array.isArray(settings) ? settings : [];
|
const settingList = Array.isArray(settings) ? settings : [];
|
||||||
|
|
||||||
const srvEpSetting = settingList.find(s => s.key === 'server_endpoint')?.value || '';
|
const hostSetting = settingList.find(s => s.key === 'wireguard.server_host')?.value?.trim();
|
||||||
const pubEpSetting = settingList.find(s => s.key === 'public_endpoint')?.value || '';
|
const portSetting = settingList.find(s => s.key === 'wireguard.server_port')?.value?.trim() || '51820';
|
||||||
const currentEndpoint = srvEpSetting || pubEpSetting || '';
|
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 = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-title-group">
|
<div class="page-title-group">
|
||||||
<h1>Settings</h1>
|
<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>
|
</div>
|
||||||
<div class="card-stack">
|
<div class="card-stack">
|
||||||
<!-- Persistent WireGuard Server Endpoint Card -->
|
<!-- Persistent WireGuard Server Endpoint Card -->
|
||||||
<div class="card" style="border-left: 4px solid var(--accent-primary, #3b82f6);">
|
<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;">
|
<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>
|
||||||
<div id="server-endpoint-alert" style="display: none; margin-bottom: 12px;" class="alert-box"></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;">
|
<div class="form-grid-2" style="max-width: 680px; margin-bottom: 14px;">
|
||||||
<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" />
|
<div class="form-group">
|
||||||
<button class="btn btn-primary" onclick="saveServerEndpoint()">Save Endpoint</button>
|
<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>
|
||||||
</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');
|
const alertBox = document.getElementById('server-endpoint-alert');
|
||||||
if (alertBox) alertBox.style.display = 'none';
|
if (alertBox) alertBox.style.display = 'none';
|
||||||
|
|
||||||
const inputVal = document.getElementById('setting-server-endpoint')?.value?.trim();
|
const hostInput = document.getElementById('setting-wg-server-host')?.value?.trim() || '';
|
||||||
if (!inputVal) {
|
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) {
|
if (alertBox) {
|
||||||
alertBox.className = 'alert-box danger';
|
alertBox.className = 'alert-box danger';
|
||||||
alertBox.style.display = 'block';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await api('/system/settings', {
|
if (hostInput) {
|
||||||
method: 'PUT',
|
if (hostInput.includes(':') && !hostInput.startsWith('[')) {
|
||||||
body: JSON.stringify({
|
const isIpv6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/.test(hostInput);
|
||||||
key: 'server_endpoint',
|
if (!isIpv6) {
|
||||||
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.';
|
|
||||||
}
|
|
||||||
setTimeout(() => renderPage('settings'), 1200);
|
|
||||||
} else {
|
|
||||||
const errMsg = extractErrorMessage(res);
|
|
||||||
if (alertBox) {
|
if (alertBox) {
|
||||||
alertBox.className = 'alert-box danger';
|
alertBox.className = 'alert-box danger';
|
||||||
alertBox.style.display = 'block';
|
alertBox.style.display = 'block';
|
||||||
alertBox.textContent = '❌ Failed to save endpoint: ' + errMsg;
|
alertBox.textContent = '❌ Server Host must not include a port. Please specify the port in the Client Endpoint Port field.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '❌ 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 ──────────────────────────────────────────────────────
|
// ── Backups Management ──────────────────────────────────────────────────────
|
||||||
async function renderBackupsPage(container) {
|
async function renderBackupsPage(container) {
|
||||||
const backups = await api('/backups') || [];
|
const backups = await api('/backups') || [];
|
||||||
|
|||||||
@@ -607,38 +607,15 @@ async fn resolve_server_endpoint(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
query: &ClientProfileQuery,
|
query: &ClientProfileQuery,
|
||||||
) -> ApiResult<String> {
|
) -> ApiResult<String> {
|
||||||
// 1. Explicit query parameter (server_endpoint or endpoint)
|
let explicit_override = query
|
||||||
if let Some(ep) = query
|
|
||||||
.server_endpoint
|
.server_endpoint
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(query.endpoint.as_deref())
|
.or(query.endpoint.as_deref());
|
||||||
{
|
state
|
||||||
let trimmed = ep.trim();
|
.store
|
||||||
if !trimmed.is_empty() {
|
.resolve_server_endpoint(explicit_override)
|
||||||
return Ok(trimmed.to_string());
|
.await
|
||||||
}
|
.map_err(|e| ApiError::Validation(e.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(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[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)"
|
"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);
|
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)
|
.set_setting(key_trimmed, val_trimmed, is_secret)
|
||||||
.await?;
|
.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 {
|
state.broadcast(SystemEvent::SettingsChanged {
|
||||||
key: key_trimmed.to_string(),
|
key: key_trimmed.to_string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use crate::error::{ApiError, ApiResult};
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::http::header::COOKIE;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -14,24 +16,66 @@ pub struct WsAuthQuery {
|
|||||||
pub session: Option<String>,
|
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
|
/// GET /api/v1/ws
|
||||||
pub async fn ws_handler(
|
pub async fn ws_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<WsAuthQuery>,
|
Query(query): Query<WsAuthQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Authenticate WebSocket connection via query parameters
|
if !authenticate_websocket(&state, &query, &headers).await {
|
||||||
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 {
|
|
||||||
return Err(ApiError::Unauthenticated(
|
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 sender, mut receiver) = socket.split();
|
||||||
let mut rx = state.event_tx.subscribe();
|
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 {
|
let mut send_task = tokio::spawn(async move {
|
||||||
while let Ok(event) = rx.recv().await {
|
while let Ok(event) = rx.recv().await {
|
||||||
if let Ok(json) = serde_json::to_string(&event) {
|
if let Ok(json) = serde_json::to_string(&event) {
|
||||||
let msg = Message::Text(json.into());
|
let msg = Message::Text(json.into());
|
||||||
|
|
||||||
if sender.send(msg).await.is_err() {
|
if sender.send(msg).await.is_err() {
|
||||||
break;
|
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 {
|
let mut recv_task = tokio::spawn(async move {
|
||||||
while let Some(Ok(msg)) = receiver.next().await {
|
while let Some(Ok(msg)) = receiver.next().await {
|
||||||
if let Message::Close(_) = msg {
|
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! {
|
tokio::select! {
|
||||||
_ = (&mut send_task) => recv_task.abort(),
|
_ = (&mut send_task) => {
|
||||||
_ = (&mut recv_task) => send_task.abort(),
|
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"));
|
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]
|
#[tokio::test]
|
||||||
async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
||||||
let (state_orig, iface, peer, session_id) = setup_test_context().await;
|
let (state_orig, iface, peer, session_id) = setup_test_context().await;
|
||||||
|
|||||||
@@ -29,3 +29,27 @@ impl std::fmt::Debug for Setting {
|
|||||||
.finish()
|
.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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -355,4 +469,67 @@ mod tests {
|
|||||||
assert!(validate_client_mtu(9001).is_err());
|
assert!(validate_client_mtu(9001).is_err());
|
||||||
assert!(validate_client_mtu(65535).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
|
## 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.
|
- **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 in later phases.
|
- **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-db`. Neither `nx9-core`, `nx9-api`, `nx9-ui`, `nx9-wireguard`, nor `nx9-network` issue SQL directly.
|
- **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
|
## 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 busy_timeout = 5000` — 5-second busy timeout to avoid contention errors.
|
||||||
- `PRAGMA synchronous = NORMAL` — Optimal reliability and performance in WAL mode.
|
- `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.
|
1. `admin` — Single administrator identity (`CHECK (id = 1)`), Argon2id password hash, TOTP secrets, and login timestamp.
|
||||||
2. `sessions` — Admin web sessions (`ON DELETE CASCADE`).
|
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.
|
7. `networks` — Named network CIDRs for routing and organization.
|
||||||
8. `routes` — Desired kernel routing rules (`ON DELETE SET NULL`).
|
8. `routes` — Desired kernel routing rules (`ON DELETE SET NULL`).
|
||||||
9. `firewall_rules` — Desired firewall policy rules with priorities and directions (`in`, `out`, `forward`).
|
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.
|
11. `audit_events` — Append-only operational audit log with event filtering and pagination.
|
||||||
12. `backups` — Backup metadata and manifest checksum records.
|
12. `backups` — Backup metadata and manifest checksum records.
|
||||||
|
13. `client_profiles` — Device, connection, and MTU transport profile specifications with built-in protections.
|
||||||
|
|
||||||
## Migration Strategy
|
## 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 executed automatically via `store.migrate().await?`.
|
||||||
- Migrations are tracked in the `_sqlx_migrations` table for idempotency.
|
- Migrations are tracked in the `_sqlx_migrations` table for idempotency.
|
||||||
|
|
||||||
## Usage in Code
|
## Usage in Code
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use nx9_db::Store;
|
use nx9_wg_db::Store;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[tokio::main]
|
#[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:
|
Tests use isolated in-memory or temporary file SQLite instances:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p nx9-db
|
cargo test -p nx9-wg-db
|
||||||
```
|
```
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
use crate::error::{DbError, Result};
|
use crate::error::{DbError, Result};
|
||||||
use crate::models::{format_datetime, parse_datetime};
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
use chrono::Utc;
|
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};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
/// Retrieve a setting by its key.
|
/// Retrieve a setting by its key.
|
||||||
@@ -99,3 +103,254 @@ pub async fn list_settings(pool: &SqlitePool) -> Result<Vec<Setting>> {
|
|||||||
|
|
||||||
Ok(list)
|
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
|
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
|
// Audit
|
||||||
pub async fn create_audit_event(
|
pub async fn create_audit_event(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -225,3 +225,171 @@ async fn test_backup_metadata_crud() {
|
|||||||
.is_none()
|
.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_connection: ConnectionType,
|
||||||
pub selected_nat: NatType,
|
pub selected_nat: NatType,
|
||||||
pub manual_mtu_override: Option<u16>,
|
pub manual_mtu_override: Option<u16>,
|
||||||
|
pub server_endpoint: Option<String>,
|
||||||
|
pub is_default_from_settings: bool,
|
||||||
pub resolved_profile: Option<ResolvedClientProfile>,
|
pub resolved_profile: Option<ResolvedClientProfile>,
|
||||||
pub is_override_enabled: bool,
|
pub is_override_enabled: bool,
|
||||||
pub qr_view_active: bool,
|
pub qr_view_active: bool,
|
||||||
@@ -26,6 +28,8 @@ impl Default for ClientExportState {
|
|||||||
selected_connection: ConnectionType::Web,
|
selected_connection: ConnectionType::Web,
|
||||||
selected_nat: NatType::Unknown,
|
selected_nat: NatType::Unknown,
|
||||||
manual_mtu_override: None,
|
manual_mtu_override: None,
|
||||||
|
server_endpoint: None,
|
||||||
|
is_default_from_settings: false,
|
||||||
resolved_profile: None,
|
resolved_profile: None,
|
||||||
is_override_enabled: false,
|
is_override_enabled: false,
|
||||||
qr_view_active: false,
|
qr_view_active: false,
|
||||||
@@ -59,6 +63,12 @@ impl ClientExportState {
|
|||||||
self.selected_provider = provider;
|
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.
|
/// Toggle or set manual MTU override.
|
||||||
pub fn set_manual_mtu(&mut self, mtu: Option<u16>) {
|
pub fn set_manual_mtu(&mut self, mtu: Option<u16>) {
|
||||||
self.manual_mtu_override = mtu;
|
self.manual_mtu_override = mtu;
|
||||||
@@ -95,6 +105,14 @@ impl ClientExportState {
|
|||||||
if let Some(m) = self.manual_mtu_override {
|
if let Some(m) = self.manual_mtu_override {
|
||||||
params.push(format!("mtu={m}"));
|
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 {
|
if let Some(id) = profile_id {
|
||||||
params.push(format!("profile={}", urlencoding(id)));
|
params.push(format!("profile={}", urlencoding(id)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ pub struct SettingsState {
|
|||||||
pub default_interface: String,
|
pub default_interface: String,
|
||||||
pub default_mtu: u16,
|
pub default_mtu: u16,
|
||||||
pub default_listen_port: 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
|
// Card 3: Networking
|
||||||
pub nat_enabled: bool,
|
pub nat_enabled: bool,
|
||||||
@@ -46,6 +49,9 @@ impl Default for SettingsState {
|
|||||||
default_interface: "wg0".to_string(),
|
default_interface: "wg0".to_string(),
|
||||||
default_mtu: 1420,
|
default_mtu: 1420,
|
||||||
default_listen_port: 51820,
|
default_listen_port: 51820,
|
||||||
|
wireguard_server_host: String::new(),
|
||||||
|
wireguard_server_port: 51820,
|
||||||
|
wireguard_server_endpoint_enabled: true,
|
||||||
nat_enabled: true,
|
nat_enabled: true,
|
||||||
ipv4_forwarding: true,
|
ipv4_forwarding: true,
|
||||||
ipv6_forwarding: false,
|
ipv6_forwarding: false,
|
||||||
|
|||||||
+17
-13
@@ -6,10 +6,10 @@ The `nx9-wg` API daemon serves JSON REST endpoints and a real-time WebSocket eve
|
|||||||
|
|
||||||
## 1. Authentication & Session Model
|
## 1. Authentication & Session Model
|
||||||
|
|
||||||
Authentication is supported via two mechanisms:
|
Authentication is supported via two secure mechanisms:
|
||||||
|
|
||||||
### A. HTTP Session Cookie (`nx9_session`)
|
### A. HTTP Session Cookie (`nx9_session`)
|
||||||
Obtained via `POST /api/v1/auth/login`. Returned in the `Set-Cookie` response header and automatically attached by web browsers.
|
Obtained via `POST /api/v1/auth/login`. Returned in the `Set-Cookie` response header with `HttpOnly; SameSite=Strict; Path=/` and automatically attached by web browsers for both REST API requests and WebSocket upgrade handshakes.
|
||||||
|
|
||||||
### B. Bearer API Token
|
### B. Bearer API Token
|
||||||
Passed in the `Authorization` header:
|
Passed in the `Authorization` header:
|
||||||
@@ -36,8 +36,8 @@ All non-2xx responses return a structured JSON error body:
|
|||||||
- `401 Unauthorized`: Missing, invalid, or expired session/token.
|
- `401 Unauthorized`: Missing, invalid, or expired session/token.
|
||||||
- `404 Not Found`: Resource ID does not exist in SQLite.
|
- `404 Not Found`: Resource ID does not exist in SQLite.
|
||||||
- `409 Conflict`: Unique constraint violation (e.g. duplicate interface name or IP).
|
- `409 Conflict`: Unique constraint violation (e.g. duplicate interface name or IP).
|
||||||
- `422 Unprocessable Entity`: Semantic constraint failure.
|
- `422 Unprocessable Entity`: Semantic constraint failure (e.g. invalid server host with embedded port, invalid port bounds).
|
||||||
- `429 Too Many Requests`: Brute-force rate limiting triggered.
|
- `429 Too Many Requests`: Brute-force rate limiting triggered (5 failed logins in 15 minutes).
|
||||||
- `500 Internal Server Error`: Native Linux execution plane or storage failure.
|
- `500 Internal Server Error`: Native Linux execution plane or storage failure.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -58,20 +58,20 @@ All non-2xx responses return a structured JSON error body:
|
|||||||
- `GET /api/v1/system/version`: Public version and build information.
|
- `GET /api/v1/system/version`: Public version and build information.
|
||||||
- `GET /api/v1/system`: System operational overview and interface/peer counts.
|
- `GET /api/v1/system`: System operational overview and interface/peer counts.
|
||||||
- `GET /api/v1/system/settings`: List all key-value settings.
|
- `GET /api/v1/system/settings`: List all key-value settings.
|
||||||
- `PUT /api/v1/system/settings`: Upsert setting `{ "key": "...", "value": "...", "description": "..." }`.
|
- `PUT /api/v1/system/settings`: Upsert setting `{ "key": "wireguard.server_host", "value": "vpn.thakares.com", "is_secret": false, "description": "..." }`. Supports `wireguard.server_host`, `wireguard.server_port`, and `wireguard.server_endpoint_enabled`.
|
||||||
|
|
||||||
### WireGuard Interfaces
|
### WireGuard Interfaces
|
||||||
- `GET /api/v1/interfaces`: List all WireGuard interfaces.
|
- `GET /api/v1/interfaces`: List all WireGuard interfaces.
|
||||||
- `POST /api/v1/interfaces`: Create interface `{ "name": "wg0", "address_v4": "10.100.0.1/24", "listen_port": 51820, "mtu": 1420 }`.
|
- `POST /api/v1/interfaces`: Create interface `{ "name": "wg0", "address_v4": "10.100.0.1/24", "listen_port": 51820, "mtu": 1420 }`.
|
||||||
- `GET /api/v1/interfaces/{id}`: Get interface details.
|
- `GET /api/v1/interfaces/{id}`: Get interface details.
|
||||||
- `PUT /api/v1/interfaces/{id}`: Update interface configuration.
|
- `PUT /api/v1/interfaces/{id}`: Update interface configuration (preserves private/public cryptographic identity).
|
||||||
- `DELETE /api/v1/interfaces/{id}`: Delete interface (cascades to peers).
|
- `DELETE /api/v1/interfaces/{id}`: Delete interface (cascades to peers).
|
||||||
- `POST /api/v1/interfaces/{id}/enable`: Set interface `IFF_UP`.
|
- `POST /api/v1/interfaces/{id}/enable`: Set interface `IFF_UP`.
|
||||||
- `POST /api/v1/interfaces/{id}/disable`: Set interface `IFF_DOWN`.
|
- `POST /api/v1/interfaces/{id}/disable`: Set interface `IFF_DOWN`.
|
||||||
- `GET /api/v1/interfaces/{id}/status`: Query live kernel netlink telemetry.
|
- `GET /api/v1/interfaces/{id}/status`: Query live kernel netlink telemetry.
|
||||||
|
|
||||||
### Peers & Client Configs
|
### Peers & Client Configs
|
||||||
- `GET /api/v1/peers`: List all peers across all interfaces.
|
- `GET /api/v1/peers`: List all peers across all interfaces (enriched with live kernel telemetry).
|
||||||
- `GET /api/v1/interfaces/{id}/peers`: List peers for specific interface.
|
- `GET /api/v1/interfaces/{id}/peers`: List peers for specific interface.
|
||||||
- `POST /api/v1/interfaces/{id}/peers`: Enroll peer `{ "name": "alice", "profile": "full_tunnel", "mtu": 1280, ... }`.
|
- `POST /api/v1/interfaces/{id}/peers`: Enroll peer `{ "name": "alice", "profile": "full_tunnel", "mtu": 1280, ... }`.
|
||||||
- `GET /api/v1/peers/{id}`: Get peer details.
|
- `GET /api/v1/peers/{id}`: Get peer details.
|
||||||
@@ -79,8 +79,12 @@ All non-2xx responses return a structured JSON error body:
|
|||||||
- `DELETE /api/v1/peers/{id}`: Delete peer.
|
- `DELETE /api/v1/peers/{id}`: Delete peer.
|
||||||
- `POST /api/v1/peers/{id}/enable`: Enable peer.
|
- `POST /api/v1/peers/{id}/enable`: Enable peer.
|
||||||
- `POST /api/v1/peers/{id}/disable`: Disable peer.
|
- `POST /api/v1/peers/{id}/disable`: Disable peer.
|
||||||
- `GET /api/v1/peers/{id}/config`: Download WireGuard `.conf` file (supports `?device=...&connection=...`).
|
- `GET /api/v1/peers/{id}/config`: Download WireGuard `.conf` file (supports `?device=...&connection=...&endpoint=...`). Uses persistent `wireguard.server_*` settings if `endpoint` query parameter is omitted.
|
||||||
- `GET /api/v1/peers/{id}/qr`: Generate QR code SVG/PNG (supports `?qr_format=svg`).
|
- `GET /api/v1/peers/{id}/qr`: Generate QR code SVG/PNG (supports `?qr_format=svg&endpoint=...`). Returns `{ "svg": "<svg...", "data_url": "data:image/png;base64,..." }`.
|
||||||
|
|
||||||
|
### Client Profiles & MTU Resolution
|
||||||
|
- `GET /api/v1/client-profiles`: List client transport profiles.
|
||||||
|
- `GET /api/v1/client-profiles/resolve`: Resolve optimal MTU and keepalive parameters for device and connection environment.
|
||||||
|
|
||||||
### Networks & Subnets
|
### Networks & Subnets
|
||||||
- `GET /api/v1/networks`: List subnet networks.
|
- `GET /api/v1/networks`: List subnet networks.
|
||||||
@@ -95,8 +99,8 @@ All non-2xx responses return a structured JSON error body:
|
|||||||
- `DELETE /api/v1/routes/{id}`: Delete route.
|
- `DELETE /api/v1/routes/{id}`: Delete route.
|
||||||
|
|
||||||
### Firewall & NAT
|
### Firewall & NAT
|
||||||
- `GET /api/v1/firewall/rules`: List nftables rules.
|
- `GET /api/v1/firewall/rules`: List nftables rules in `table inet nx9_wg`.
|
||||||
- `POST /api/v1/firewall/rules`: Create rule `{ "name": "allow-ssh", "protocol": "tcp", "port": "22", "action": "accept" }`.
|
- `POST /api/v1/firewall/rules`: Create rule `{ "name": "allow-ssh", "protocol": "tcp", "port": 22, "action": "accept" }`.
|
||||||
- `DELETE /api/v1/firewall/rules/{id}`: Delete rule.
|
- `DELETE /api/v1/firewall/rules/{id}`: Delete rule.
|
||||||
- `POST /api/v1/firewall/rules/{id}/enable`: Enable rule.
|
- `POST /api/v1/firewall/rules/{id}/enable`: Enable rule.
|
||||||
- `POST /api/v1/firewall/rules/{id}/disable`: Disable rule.
|
- `POST /api/v1/firewall/rules/{id}/disable`: Disable rule.
|
||||||
@@ -106,7 +110,7 @@ All non-2xx responses return a structured JSON error body:
|
|||||||
- `POST /api/v1/reconcile/apply`: Execute native mutations and verify convergence.
|
- `POST /api/v1/reconcile/apply`: Execute native mutations and verify convergence.
|
||||||
|
|
||||||
### Diagnostics
|
### Diagnostics
|
||||||
- `GET /api/v1/diagnostics/all`: Run automated checks across all 9 subsystems.
|
- `GET /api/v1/diagnostics/all`: Run automated checks across all subsystems.
|
||||||
- `GET /api/v1/diagnostics/{subsystem}`: Run checks for a single subsystem.
|
- `GET /api/v1/diagnostics/{subsystem}`: Run checks for a single subsystem.
|
||||||
|
|
||||||
### Backups
|
### Backups
|
||||||
@@ -123,7 +127,7 @@ All non-2xx responses return a structured JSON error body:
|
|||||||
|
|
||||||
## 4. Real-Time WebSocket Protocol (`/api/v1/ws`)
|
## 4. Real-Time WebSocket Protocol (`/api/v1/ws`)
|
||||||
|
|
||||||
Connect to `ws://<host>/api/v1/ws` (or `wss://`) to receive real-time JSON events:
|
Connect to `ws://<host>/api/v1/ws` (or `wss://`) to receive real-time JSON events. Authentication is handled automatically using the browser's `nx9_session` cookie or `Authorization: Bearer <token>` header:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
+94
-49
@@ -1,6 +1,6 @@
|
|||||||
# Native CLI Command Reference (`nx9-wg`)
|
# Native CLI Command Reference (`nx9-wg`)
|
||||||
|
|
||||||
The `nx9-wg` binary provides 100% native CLI coverage across all 17 application subcommands without spawning external subprocesses.
|
The `nx9-wg` binary provides native CLI coverage across all 18 application command groups without spawning external subprocesses.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,9 +36,9 @@ nx9-wg serve --bind 0.0.0.0:8080
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. `init`
|
### 3. `init`
|
||||||
Initializes the single administrator account across 7 bootstrap sources.
|
Initializes the single administrator account across bootstrap sources.
|
||||||
```bash
|
```bash
|
||||||
# Generated secure password:
|
# Generate secure random password written to a restricted file:
|
||||||
nx9-wg init --generate-password --write-password-file /var/lib/nx9-wg/admin-password
|
nx9-wg init --generate-password --write-password-file /var/lib/nx9-wg/admin-password
|
||||||
|
|
||||||
# Password via stdin:
|
# Password via stdin:
|
||||||
@@ -54,79 +54,124 @@ nx9-wg init --password-file /run/secrets/admin_pw
|
|||||||
- `nx9-wg system info`: System platform, architecture, and runtime paths.
|
- `nx9-wg system info`: System platform, architecture, and runtime paths.
|
||||||
- `nx9-wg system settings list`: List all key-value settings.
|
- `nx9-wg system settings list`: List all key-value settings.
|
||||||
- `nx9-wg system settings get <KEY>`: Query setting value.
|
- `nx9-wg system settings get <KEY>`: Query setting value.
|
||||||
- `nx9-wg system settings set <KEY> <VALUE> [--secret]`: Save setting.
|
- `nx9-wg system settings set <KEY> <VALUE> [--secret]`: Save setting (validates `wireguard.server_host`, `wireguard.server_port`, `wireguard.server_endpoint_enabled`).
|
||||||
- `nx9-wg system settings delete <KEY>`: Delete setting.
|
- `nx9-wg system settings delete <KEY>`: Delete setting.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure persistent WireGuard server endpoint:
|
||||||
|
nx9-wg system settings set wireguard.server_host vpn.thakares.com
|
||||||
|
nx9-wg system settings set wireguard.server_port 51820
|
||||||
|
nx9-wg system settings set wireguard.server_endpoint_enabled true
|
||||||
|
```
|
||||||
|
|
||||||
### 5. `admin`
|
### 5. `admin`
|
||||||
- `nx9-wg admin info`: Query administrator account metadata.
|
- `nx9-wg admin status`: Query administrator account metadata.
|
||||||
- `nx9-wg admin password`: Change administrator password.
|
- `nx9-wg admin create [--username U] [--password P | --password-stdin | --generate-password]`: Bootstrap admin if uninitialized.
|
||||||
- `nx9-wg admin token create <NAME> [--expires-in-days N] [--write-token-file PATH]`: Generate API token.
|
- `nx9-wg admin password [--new-password P | --stdin | --password-file F | --generate]`: Change administrator password.
|
||||||
- `nx9-wg admin token list`: List active API tokens.
|
- `nx9-wg admin tokens create --name <NAME> [--days N] [--write-token-file PATH]`: Generate API token.
|
||||||
- `nx9-wg admin token revoke <TOKEN_ID>`: Revoke an API token.
|
- `nx9-wg admin tokens list`: List active API tokens.
|
||||||
- `nx9-wg admin session list`: List active browser sessions.
|
- `nx9-wg admin tokens revoke <TOKEN_ID>`: Revoke an API token.
|
||||||
- `nx9-wg admin session revoke-all`: Invalidate all active sessions.
|
- `nx9-wg admin sessions list`: List active browser sessions.
|
||||||
|
- `nx9-wg admin sessions revoke <SESSION_ID>`: Revoke an active session.
|
||||||
|
- `nx9-wg admin sessions revoke-all`: Invalidate all active sessions.
|
||||||
|
|
||||||
### 6. `interface`
|
### 6. `interface`
|
||||||
- `nx9-wg interface list`: List all WireGuard interfaces.
|
- `nx9-wg interface list`: List all WireGuard interfaces.
|
||||||
- `nx9-wg interface create <NAME> --address-v4 <CIDR> [--port PORT] [--mtu MTU]`: Create interface.
|
- `nx9-wg interface create <NAME> --address-v4 <CIDR> [--address-v6 <CIDR>] [--port PORT] [--mtu MTU] [--dns DNS]`: Create interface.
|
||||||
- `nx9-wg interface show <NAME_OR_ID>`: Show interface configuration.
|
- `nx9-wg interface show <NAME_OR_ID>`: Show interface details.
|
||||||
|
- `nx9-wg interface update <NAME_OR_ID> [--port P] [--address-v4 A] [--address-v6 A] [--mtu M] [--dns D] [--enabled BOOL]`: Update interface.
|
||||||
- `nx9-wg interface enable <NAME_OR_ID>`: Enable interface (`IFF_UP`).
|
- `nx9-wg interface enable <NAME_OR_ID>`: Enable interface (`IFF_UP`).
|
||||||
- `nx9-wg interface disable <NAME_OR_ID>`: Disable interface (`IFF_DOWN`).
|
- `nx9-wg interface disable <NAME_OR_ID>`: Disable interface (`IFF_DOWN`).
|
||||||
- `nx9-wg interface delete <NAME_OR_ID>`: Delete interface.
|
- `nx9-wg interface delete <NAME_OR_ID>`: Delete interface (cascades to peers).
|
||||||
|
- `nx9-wg interface status <NAME_OR_ID>`: Show live interface status and peer metrics.
|
||||||
|
- `nx9-wg interface reconcile <NAME_OR_ID>`: Reconcile specific interface with kernel.
|
||||||
|
|
||||||
### 7. `peer`
|
### 7. `peer`
|
||||||
- `nx9-wg peer list [--interface NAME]`: List enrolled peers.
|
- `nx9-wg peer list [--interface NAME_OR_ID]`: List enrolled peers.
|
||||||
- `nx9-wg peer create --interface <IFACE> --name <NAME> [--profile PROFILE] [--mtu MTU]`: Enroll peer.
|
|
||||||
- `nx9-wg peer show <PEER_ID>`: Show peer configuration.
|
- `nx9-wg peer show <PEER_ID>`: Show peer configuration.
|
||||||
|
- `nx9-wg peer create --interface <IFACE> --name <NAME> [--peer-type TYPE] [--profile PROFILE] [--network NET] [--address-v4 CIDR] [--allowed-ips IPS] [--endpoint EP] [--persistent-keepalive SECS] [--mtu MTU] [--expires-at RFC3339]`: Enroll peer.
|
||||||
|
- `nx9-wg peer update <PEER_ID> [--name N] [--allowed-ips IPS] [--endpoint EP] [--persistent-keepalive SECS] [--mtu M] [--enabled BOOL]`: Update peer.
|
||||||
- `nx9-wg peer enable <PEER_ID>` / `disable <PEER_ID>`: Toggle peer state.
|
- `nx9-wg peer enable <PEER_ID>` / `disable <PEER_ID>`: Toggle peer state.
|
||||||
|
- `nx9-wg peer revoke <PEER_ID>`: Revoke peer.
|
||||||
|
- `nx9-wg peer expire <PEER_ID>`: Mark peer as expired.
|
||||||
|
- `nx9-wg peer lifecycle <PEER_ID>`: Show peer lifecycle metadata.
|
||||||
|
- `nx9-wg peer status <PEER_ID>`: Show live peer status and telemetry.
|
||||||
- `nx9-wg peer delete <PEER_ID>`: Delete peer.
|
- `nx9-wg peer delete <PEER_ID>`: Delete peer.
|
||||||
- `nx9-wg peer config <PEER_ID> [--device DEV] [--connection CONN]`: Output `.conf` client file.
|
- `nx9-wg peer config <PEER_ID> [--provider PROV] [--device DEV] [--connection CONN] [--nat NAT] [--mtu MTU] [--profile PROF] [--endpoint EP] [--output PATH]`: Export `.conf` client file (uses persistent `wireguard.server_*` settings or `--endpoint` override).
|
||||||
- `nx9-wg peer qr <PEER_ID>`: Render ASCII QR code in terminal for mobile scanning.
|
- `nx9-wg peer qr <PEER_ID> [--provider PROV] [--device DEV] [--connection CONN] [--nat NAT] [--mtu MTU] [--profile PROF] [--endpoint EP] [--qr-format terminal|svg|png]`: Render QR code in terminal, SVG, or PNG format.
|
||||||
|
|
||||||
### 8. `network`
|
### 8. `profile`
|
||||||
|
- `nx9-wg profile list [--provider PROV] [--device DEV] [--connection CONN] [--nat NAT]`: List client configuration profiles.
|
||||||
|
- `nx9-wg profile show <PROFILE_ID>`: Show details of a client profile (e.g. `default-mobile`, `android-mobile`).
|
||||||
|
- `nx9-wg profile validate <MTU>`: Validate MTU against safe operational limits.
|
||||||
|
- `nx9-wg profile resolve [--provider PROV] [--device DEV] [--connection CONN] [--nat NAT] [--mtu MTU] [--profile PROF]`: Resolve optimal client profile and MTU.
|
||||||
|
|
||||||
|
### 9. `network`
|
||||||
- `nx9-wg network list`: List subnet networks.
|
- `nx9-wg network list`: List subnet networks.
|
||||||
- `nx9-wg network create <NAME> --cidr <CIDR>`: Create network.
|
- `nx9-wg network show <ID>`: Show network details.
|
||||||
- `nx9-wg network delete <NAME_OR_ID>`: Delete network.
|
- `nx9-wg network create <NAME> --cidr <CIDR> [--description DESC]`: Create network.
|
||||||
|
- `nx9-wg network available <ID> [--limit N] [--interface IFACE]`: Show available unallocated IP addresses.
|
||||||
|
- `nx9-wg network allocations <ID>`: Show allocated IP addresses and peer mappings.
|
||||||
|
- `nx9-wg network update <ID> [--name N] [--cidr C] [--description D] [--enabled BOOL]`: Update network.
|
||||||
|
- `nx9-wg network delete <ID>`: Delete subnet network.
|
||||||
|
|
||||||
### 9. `route`
|
### 10. `route`
|
||||||
- `nx9-wg route list`: List routing table entries.
|
- `nx9-wg route list`: List configured routing rules.
|
||||||
- `nx9-wg route add --destination <CIDR> [--gateway IP] [--interface-name IFACE] [--metric M]`: Add route.
|
- `nx9-wg route show <ID>`: Show route details.
|
||||||
- `nx9-wg route delete <ROUTE_ID>`: Delete route.
|
- `nx9-wg route add --destination <CIDR> [--gateway IP] [--interface-name IFACE] [--metric M]`: Add kernel routing rule.
|
||||||
|
- `nx9-wg route update <ID> [--destination C] [--gateway IP] [--interface-name IFACE] [--metric M] [--enabled BOOL]`: Update route.
|
||||||
|
- `nx9-wg route delete <ID>`: Delete routing rule.
|
||||||
|
- `nx9-wg route status`: Show current kernel routing status.
|
||||||
|
- `nx9-wg route sync`: Synchronize routes with kernel routing table.
|
||||||
|
|
||||||
### 10. `firewall`
|
### 11. `firewall`
|
||||||
- `nx9-wg firewall list`: List nftables firewall rules.
|
- `nx9-wg firewall list [--peer PEER]`: List configured nftables rules.
|
||||||
- `nx9-wg firewall add --name <NAME> [--protocol PROTO] [--port PORT] [--action ACTION] [--priority P]`: Add rule.
|
- `nx9-wg firewall show <ID>`: Show firewall rule details.
|
||||||
- `nx9-wg firewall enable <RULE_ID>` / `disable <RULE_ID>`: Toggle rule.
|
- `nx9-wg firewall add --name <NAME> [--direction in|out|forward] [--source CIDR] [--destination CIDR] [--peer PEER] [--protocol tcp|udp|tcp_udp|icmp|any] [--port P] [--port-range R] [--action accept|drop|reject] [--priority P]`: Add rule.
|
||||||
- `nx9-wg firewall delete <RULE_ID>`: Delete rule.
|
- `nx9-wg firewall update <ID> [--name N] [--action A] [--priority P] [--enabled BOOL]`: Update rule.
|
||||||
|
- `nx9-wg firewall enable <ID>` / `disable <ID>`: Toggle rule.
|
||||||
|
- `nx9-wg firewall delete <ID>`: Delete rule.
|
||||||
|
- `nx9-wg firewall sync`: Synchronize nftables ruleset in `table inet nx9_wg`.
|
||||||
|
- `nx9-wg firewall status`: Show active nftables status.
|
||||||
|
|
||||||
### 11. `nat`
|
### 12. `nat`
|
||||||
- `nx9-wg nat status`: Query NAT masquerade state.
|
- `nx9-wg nat status`: Query NAT masquerade state.
|
||||||
- `nx9-wg nat enable` / `disable`: Toggle outbound NAT masquerading.
|
- `nx9-wg nat enable` / `disable`: Toggle outbound NAT masquerading.
|
||||||
|
- `nx9-wg nat list`: List subnets configured for NAT masquerade.
|
||||||
|
- `nx9-wg nat sync`: Synchronize NAT rules with kernel.
|
||||||
|
|
||||||
### 12. `forwarding`
|
### 13. `forwarding`
|
||||||
- `nx9-wg forwarding status`: Query kernel `/proc/sys/net/ipv4/ip_forward` status.
|
- `nx9-wg forwarding status`: Query kernel `/proc/sys/net/ipv4/ip_forward` status.
|
||||||
- `nx9-wg forwarding enable` / `disable`: Toggle kernel IP forwarding.
|
- `nx9-wg forwarding enable` / `disable`: Toggle kernel IP packet forwarding.
|
||||||
|
- `nx9-wg forwarding sync`: Synchronize IP forwarding setting with kernel.
|
||||||
|
|
||||||
### 13. `reconcile`
|
### 14. `reconcile`
|
||||||
- `nx9-wg reconcile plan`: Calculate read-only drift between SQLite and kernel.
|
- `nx9-wg reconcile status`: Inspect reconciliation status and statistics.
|
||||||
- `nx9-wg reconcile apply`: Apply mutations across all execution planes.
|
- `nx9-wg reconcile plan [--interface IFACE]`: Calculate read-only drift plan between SQLite and Linux kernel.
|
||||||
- `nx9-wg reconcile verify`: Post-apply verification check.
|
- `nx9-wg reconcile apply [--interface IFACE]`: Apply reconciliation mutations to live kernel state.
|
||||||
|
- `nx9-wg reconcile verify`: Verify zero drift between SQLite and kernel.
|
||||||
|
|
||||||
### 14. `backup`
|
### 15. `backup`
|
||||||
- `nx9-wg backup list`: List backup snapshots.
|
- `nx9-wg backup list`: List backup snapshots.
|
||||||
- `nx9-wg backup create [--description DESC]`: Generate atomic SQLite online backup (`VACUUM INTO`).
|
- `nx9-wg backup create [--description DESC]`: Generate atomic SQLite online backup (`VACUUM INTO`).
|
||||||
|
- `nx9-wg backup show <ID>`: Show backup details and manifest.
|
||||||
- `nx9-wg backup verify <PATH>`: Verify SQLite 3 header and SHA-256 checksum.
|
- `nx9-wg backup verify <PATH>`: Verify SQLite 3 header and SHA-256 checksum.
|
||||||
- `nx9-wg backup restore <PATH_OR_ID>`: Restore database with automatic safety snapshot.
|
- `nx9-wg backup restore <PATH> [-y, --yes]`: Restore database with automatic safety snapshot.
|
||||||
|
- `nx9-wg backup delete <ID>`: Delete backup record and snapshot archive.
|
||||||
|
|
||||||
### 15. `audit`
|
### 16. `audit`
|
||||||
- `nx9-wg audit list [--limit N] [--event-type TYPE]`: List append-only audit trail records.
|
- `nx9-wg audit list [--event-type TYPE] [--actor ACTOR] [--resource-type TYPE] [--limit N] [--offset N]`: List append-only audit trail records.
|
||||||
|
- `nx9-wg audit show <ID>`: Show full details for an audit event.
|
||||||
|
|
||||||
### 16. `live`
|
### 17. `live`
|
||||||
- `nx9-wg live interfaces`: Query active Linux kernel WireGuard interfaces.
|
- `nx9-wg live interface list`: List live WireGuard interface names in kernel.
|
||||||
- `nx9-wg live peers <IFACE>`: Query live peers, transfer bytes, and handshakes.
|
- `nx9-wg live interface show <NAME>`: Show live interface statistics.
|
||||||
- `nx9-wg live routes`: Query live kernel routing table.
|
- `nx9-wg live peer <IFACE>`: Query live peers, transfer bytes, and handshakes.
|
||||||
- `nx9-wg live nftables`: Query active `table inet nx9_wg` ruleset.
|
- `nx9-wg live routes`: Query live Linux kernel routing table.
|
||||||
|
- `nx9-wg live firewall`: Query live active `table inet nx9_wg` nftables ruleset.
|
||||||
|
- `nx9-wg live forwarding`: Query live IP packet forwarding status.
|
||||||
|
- `nx9-wg live nat`: Query live NAT masquerade status.
|
||||||
|
|
||||||
### 17. `diagnostics`
|
### 18. `diagnostics`
|
||||||
- `nx9-wg diagnostics all`: Inspect health across all 9 subsystems.
|
- `nx9-wg diagnostics all`: Inspect health across all subsystems.
|
||||||
- `nx9-wg diagnostics <SUBSYSTEM>`: Inspect specific subsystem (`system`, `network`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `reconciliation`).
|
- `nx9-wg diagnostics <SUBSYSTEM> [--peer PEER_UUID]`: Inspect specific subsystem (`system`, `network`, `wan`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `mtu`, `reconciliation`).
|
||||||
+43
-1
@@ -59,8 +59,50 @@ Every environment variable recognized by `nx9-wg` uses the mandatory `NX9_WG_` n
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Persistent WireGuard Server Endpoint Configuration
|
||||||
|
|
||||||
|
When generating client `.conf` configurations and QR codes, `nx9-wg` embeds the public or reachable server endpoint address so client devices can reach the server. This is managed through persistent settings in SQLite.
|
||||||
|
|
||||||
|
### Settings Keys
|
||||||
|
|
||||||
|
| Key | Type | Description | Default | Example |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| `wireguard.server_host` | String | Public/reachable server hostname or IP address (DNS hostname, IPv4, or IPv6). Must not contain a port. | Empty | `vpn.thakares.com` or `203.0.113.10` or `2001:db8::10` |
|
||||||
|
| `wireguard.server_port` | u16 | Public reachable UDP port where clients connect. | `51820` | `51820` |
|
||||||
|
| `wireguard.server_endpoint_enabled` | Boolean | Whether the persistent server endpoint is used as the default for client exports. | `true` | `true` |
|
||||||
|
| `server_endpoint` | String | Legacy formatted endpoint fallback (`host:port` or `[ipv6]:port`). | Empty | `vpn.thakares.com:51820` |
|
||||||
|
| `public_endpoint` | String | Legacy secondary fallback. | Empty | `vpn.thakares.com:51820` |
|
||||||
|
|
||||||
|
### Important Architectural Invariants
|
||||||
|
|
||||||
|
- **Public Endpoint vs. Interface Listen Port**: The public server endpoint (`wireguard.server_host` and `wireguard.server_port`) is the external address that clients use to connect across the Internet or WAN. It is conceptually separate from the WireGuard interface's local kernel UDP `listen_port` (which may sit behind NAT, port-forwarding, or a reverse proxy).
|
||||||
|
- **Authoritative Resolution Precedence**:
|
||||||
|
1. **Explicit per-request / per-export override**: Passed via `--endpoint <ENDPOINT>` in the CLI or `?endpoint=<ENDPOINT>` in the REST API.
|
||||||
|
2. **Persistent structured settings**: `wireguard.server_host` + `wireguard.server_port` when `wireguard.server_endpoint_enabled` is `true` and host is non-empty.
|
||||||
|
3. **Legacy `server_endpoint` setting**: If present and non-empty.
|
||||||
|
4. **Legacy `public_endpoint` setting**: If present and non-empty.
|
||||||
|
5. **Actionable configuration error**: If no endpoint is configured, generation fails with an actionable error directing the administrator to configure the server endpoint in Settings or provide an explicit override.
|
||||||
|
|
||||||
|
### Configuring via CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure the persistent server endpoint:
|
||||||
|
nx9-wg system settings set wireguard.server_host vpn.thakares.com
|
||||||
|
nx9-wg system settings set wireguard.server_port 51820
|
||||||
|
nx9-wg system settings set wireguard.server_endpoint_enabled true
|
||||||
|
|
||||||
|
# Export a client configuration using the persistent default:
|
||||||
|
nx9-wg peer config <PEER_UUID>
|
||||||
|
# Generated output contains: Endpoint = vpn.thakares.com:51820
|
||||||
|
|
||||||
|
# Export with a temporary one-off override (does not modify persistent settings):
|
||||||
|
nx9-wg peer config <PEER_UUID> --endpoint custom.backup-vpn.com:51820
|
||||||
|
# Generated output contains: Endpoint = custom.backup-vpn.com:51820
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Secret Handling & Docker Secrets
|
## Secret Handling & Docker Secrets
|
||||||
|
|
||||||
- **Never Persisted in Cleartext**: `NX9_WG_ADMIN_PASSWORD` is hashed into SQLite using Argon2id during initialization and is never written to disk, config files, or logs.
|
- **Never Persisted in Cleartext**: `NX9_WG_ADMIN_PASSWORD` is hashed into SQLite using Argon2id during initialization and is never written to disk, config files, or logs.
|
||||||
- **Docker Secrets**: In container environments, mount Docker secrets to `/run/secrets/nx9_wg_admin_password` and specify `NX9_WG_ADMIN_PASSWORD_FILE=/run/secrets/nx9_wg_admin_password`.
|
- **Docker Secrets**: In container environments, mount Docker secrets to `/run/secrets/nx9_wg_admin_password` and specify `NX9_WG_ADMIN_PASSWORD_FILE=/run/secrets/nx9_wg_admin_password`.
|
||||||
|
|
||||||
+12
-7
@@ -10,7 +10,7 @@
|
|||||||
- **Embedded Static Assets**: HTML, CSS, and JavaScript are bundled into the binary at compile time via `include_str!()` and served from memory.
|
- **Embedded Static Assets**: HTML, CSS, and JavaScript are bundled into the binary at compile time via `include_str!()` and served from memory.
|
||||||
- **Unified Design Tokens**: Custom CSS variable design system (`nx9-wg-ui/src/css.rs`) providing Dark and Light themes with persistent `localStorage` preference.
|
- **Unified Design Tokens**: Custom CSS variable design system (`nx9-wg-ui/src/css.rs`) providing Dark and Light themes with persistent `localStorage` preference.
|
||||||
- **Responsive Layout**: Mobile-first responsive layout with side-drawer navigation and `@media (max-width: 768px)` breakpoints.
|
- **Responsive Layout**: Mobile-first responsive layout with side-drawer navigation and `@media (max-width: 768px)` breakpoints.
|
||||||
- **Live WebSocket Event Stream**: Connects to `ws://<host>/api/v1/ws` for reactive dashboard, peer handshake, and reconciliation updates without polling.
|
- **Live WebSocket Event Stream**: Connects to `ws://<host>/api/v1/ws` authenticated via the browser's `nx9_session` HttpOnly cookie for reactive dashboard, peer handshake, and reconciliation updates without polling.
|
||||||
- **Presentation-Only Separation**: The UI contains presentation and client routing logic only; all business validation, allocation, and state authority reside in the backend REST API and SQLite.
|
- **Presentation-Only Separation**: The UI contains presentation and client routing logic only; all business validation, allocation, and state authority reside in the backend REST API and SQLite.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
| Hash Route | Navigation Label | Purpose & Operational Features |
|
| Hash Route | Navigation Label | Purpose & Operational Features |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `#dashboard` | **Dashboard** | System status, uptime, interface/peer counts, diagnostics health, and reconciliation status cards. |
|
| `#dashboard` | **Dashboard** | System status, uptime, interface/peer counts, diagnostics health, and reconciliation status cards. |
|
||||||
| `#interfaces` | **Interfaces** | List WireGuard interfaces, "+ Create Interface" modal, enable/disable toggle, and delete interface. |
|
| `#interfaces` | **Interfaces** | List WireGuard interfaces, "+ Create Interface" modal, interface "Edit" action (with cryptographic key preservation), enable/disable toggle, and delete interface. |
|
||||||
| `#peers` | **Peers** | Enrolled peer table with real-time handshakes, status filter, "+ Add Peer" modal with MTU profile resolution, client configuration export, and live SVG QR rendering. |
|
| `#peers` | **Peers** | Enrolled peer table with real-time handshakes, status filter, "+ Add Peer" modal with MTU profile resolution, client configuration export, and live SVG QR rendering. |
|
||||||
| `#networks` | **Networks** | Subnet network ranges, CIDR masks, "+ Create Network" modal, and deletion. |
|
| `#networks` | **Networks** | Subnet network ranges, CIDR masks, "+ Create Network" modal, and deletion. |
|
||||||
| `#routes` | **Routes** | Routing table entries, gateway assignments, "+ Create Route" modal, and deletion. |
|
| `#routes` | **Routes** | Routing table entries, gateway assignments, "+ Create Route" modal, and deletion. |
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
| `#nat` | **NAT & Masquerade** | Outbound NAT masquerade status card, active masqueraded subnet list, and instant toggle. |
|
| `#nat` | **NAT & Masquerade** | Outbound NAT masquerade status card, active masqueraded subnet list, and instant toggle. |
|
||||||
| `#forwarding` | **IP Forwarding** | Kernel sysctl `/proc/sys/net/ipv4/ip_forward` packet forwarding status and toggle. |
|
| `#forwarding` | **IP Forwarding** | Kernel sysctl `/proc/sys/net/ipv4/ip_forward` packet forwarding status and toggle. |
|
||||||
| `#reconciliation` | **Reconciliation** | Real-time kernel drift overview, planned execution actions table, and interactive "Run Reconcile (Apply)" button. |
|
| `#reconciliation` | **Reconciliation** | Real-time kernel drift overview, planned execution actions table, and interactive "Run Reconcile (Apply)" button. |
|
||||||
| `#diagnostics` | **Diagnostics** | Automated health inspection across all 9 subsystems (`system`, `network`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `reconciliation`) with remediation hints. |
|
| `#diagnostics` | **Diagnostics** | Automated health inspection across all subsystems (`system`, `network`, `wan`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `mtu`, `reconciliation`) with remediation hints. |
|
||||||
| `#live-state` | **Live State** | Live Linux Netlink kernel telemetry, active WireGuard interfaces, and live routing table entries. |
|
| `#live-state` | **Live State** | Live Linux Netlink kernel telemetry, active WireGuard interfaces, and live routing table entries. |
|
||||||
| `#settings` | **Settings** | Appliance key-value parameters table and danger zone reset controls. |
|
| `#settings` | **Settings** | Dedicated **WireGuard Server Endpoint** configuration card (Host, Port, Enabled toggle, live preview, save), appliance parameters table, and danger zone reset controls. |
|
||||||
| `#backups` | **Backups** | Atomic SQLite database backup snapshots list, "+ Create Backup Snapshot" button, and direct `.db` download. |
|
| `#backups` | **Backups** | Atomic SQLite database backup snapshots list, "+ Create Backup Snapshot" button, and direct `.db` download. |
|
||||||
| `#audit` | **Audit Log** | Append-only security and administrative audit trail with actor, IP, timestamp, and metadata. |
|
| `#audit` | **Audit Log** | Append-only security and administrative audit trail with actor, IP, timestamp, and metadata. |
|
||||||
| `#administrator` | **Administrator** | Admin account verification, "Change Password" modal, and "+ Generate API Token" modal with one-time raw secret copy. |
|
| `#administrator` | **Administrator** | Admin account verification, "Change Password" modal, and "+ Generate API Token" modal with one-time raw secret copy. |
|
||||||
@@ -43,9 +43,14 @@
|
|||||||
When enrolling a new peer (`#peers`), the modal automatically queries `/api/v1/client-profiles/resolve` based on selected Device (Android, iOS, Linux, Windows, macOS) and Connection (Mobile Cellular 4G/5G, Wi-Fi, Wired Ethernet) to determine optimal MTU (1280 vs 1360 vs 1420) and persistent keepalive (25s).
|
When enrolling a new peer (`#peers`), the modal automatically queries `/api/v1/client-profiles/resolve` based on selected Device (Android, iOS, Linux, Windows, macOS) and Connection (Mobile Cellular 4G/5G, Wi-Fi, Wired Ethernet) to determine optimal MTU (1280 vs 1360 vs 1420) and persistent keepalive (25s).
|
||||||
|
|
||||||
### B. Client Export & QR Code Modal
|
### B. Client Export & QR Code Modal
|
||||||
Displays both:
|
When opening the export modal for a peer, the UI automatically:
|
||||||
1. **Interactive Vector QR Code**: Inline SVG rendering for scanning directly with the official WireGuard mobile app.
|
1. Pre-populates the **Server Endpoint** field using the persistent settings (`wireguard.server_host` and `wireguard.server_port`) configured under Settings.
|
||||||
2. **Downloadable `.conf` File**: Standard WireGuard client configuration file formatted for instant download or clipboard copy.
|
2. Displays a `"Default from Server Settings"` badge indicating persistent configuration source.
|
||||||
|
3. Automatically triggers client `.conf` and QR code generation on modal open without requiring manual typing.
|
||||||
|
4. Allows the administrator to enter a temporary one-off endpoint override directly in the modal for specialized network requirements without mutating global server settings.
|
||||||
|
5. Displays both:
|
||||||
|
- **Interactive Vector QR Code**: Inline SVG rendering for scanning directly with the official WireGuard mobile app.
|
||||||
|
- **Downloadable `.conf` File**: Standard WireGuard client configuration file formatted for instant download or clipboard copy.
|
||||||
|
|
||||||
### C. One-Time API Token Delivery Modal
|
### C. One-Time API Token Delivery Modal
|
||||||
Generates a new API token, calculates its SHA-256 digest for SQLite storage, and presents the raw token string once in an interactive modal with a copy button.
|
Generates a new API token, calculates its SHA-256 digest for SQLite storage, and presents the raw token string once in an interactive modal with a copy button.
|
||||||
|
|||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# nx9-wg Source Repository Backup Tool
|
||||||
|
# ==============================================================================
|
||||||
|
# PURPOSE:
|
||||||
|
# Creates a timestamped, compressed source code backup archive while strictly
|
||||||
|
# excluding build artifacts (target/) and temporary databases, while retaining
|
||||||
|
# the full .git version history for recovery and auditability.
|
||||||
|
#
|
||||||
|
# PREREQUISITES:
|
||||||
|
# - tar
|
||||||
|
# - xz or gzip
|
||||||
|
# - sha256sum
|
||||||
|
#
|
||||||
|
# BEHAVIOR:
|
||||||
|
# - Non-destructive to the source tree.
|
||||||
|
# - Verifies the integrity of the generated archive.
|
||||||
|
# - Generates a companion .sha256 checksum file.
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# bash scripts/backup-source.sh [DESTINATION_DIR]
|
||||||
|
# ./scripts/backup-source.sh /backup/sources
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
DEST_DIR="${1:-${ROOT_DIR}/..}"
|
||||||
|
mkdir -p "${DEST_DIR}"
|
||||||
|
DEST_DIR="$(cd "${DEST_DIR}" && pwd)"
|
||||||
|
|
||||||
|
PROJECT_NAME="nx9-wg"
|
||||||
|
TIMESTAMP="$(date +%Y-%m-%d-%H%M%S)"
|
||||||
|
ARCHIVE_BASE="${PROJECT_NAME}-source-${TIMESTAMP}"
|
||||||
|
ARCHIVE_PATH="${DEST_DIR}/${ARCHIVE_BASE}.tar.xz"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "\033[1;34m[BACKUP-SRC]\033[0m \033[1;37m$*\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "\033[1;32m[SUCCESS]\033[0m $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Creating source backup of ${ROOT_DIR}..."
|
||||||
|
log "Destination archive: ${ARCHIVE_PATH}"
|
||||||
|
|
||||||
|
# Create archive excluding heavy/temporary build outputs
|
||||||
|
tar --exclude='target' \
|
||||||
|
--exclude='.cargo' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='*.tmp' \
|
||||||
|
--exclude='*.db' \
|
||||||
|
--exclude='*.db-wal' \
|
||||||
|
--exclude='*.db-shm' \
|
||||||
|
--exclude='*.tar.gz' \
|
||||||
|
--exclude='*.tar.xz' \
|
||||||
|
-cJf "${ARCHIVE_PATH}" \
|
||||||
|
-C "$(dirname "${ROOT_DIR}")" "$(basename "${ROOT_DIR}")" || error "Failed to create source archive."
|
||||||
|
|
||||||
|
# Verify archive integrity
|
||||||
|
log "Verifying archive integrity..."
|
||||||
|
tar -tf "${ARCHIVE_PATH}" >/dev/null || error "Archive verification check failed."
|
||||||
|
|
||||||
|
# Generate checksum
|
||||||
|
(cd "${DEST_DIR}" && sha256sum "$(basename "${ARCHIVE_PATH}")" > "${ARCHIVE_BASE}.sha256")
|
||||||
|
|
||||||
|
ARCHIVE_SIZE="$(du -h "${ARCHIVE_PATH}" | cut -f1)"
|
||||||
|
ARCHIVE_SHA="$(cat "${DEST_DIR}/${ARCHIVE_BASE}.sha256" | awk '{print $1}')"
|
||||||
|
|
||||||
|
echo -e "\n================================================================="
|
||||||
|
echo -e "\033[1;32m SOURCE BACKUP COMPLETED SUCCESSFULLY!\033[0m"
|
||||||
|
echo -e "================================================================="
|
||||||
|
echo " Archive Path: ${ARCHIVE_PATH}"
|
||||||
|
echo " Archive Size: ${ARCHIVE_SIZE}"
|
||||||
|
echo " SHA-256: ${ARCHIVE_SHA}"
|
||||||
|
echo " Checksum File:${DEST_DIR}/${ARCHIVE_BASE}.sha256"
|
||||||
|
echo -e "=================================================================\n"
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# nx9-wg Production Release Builder
|
||||||
|
# ==============================================================================
|
||||||
|
# PURPOSE:
|
||||||
|
# Runs release quality gates and compiles an optimized release binary:
|
||||||
|
# 1. Format verification
|
||||||
|
# 2. Check & Clippy (-D warnings)
|
||||||
|
# 3. Full workspace test suite
|
||||||
|
# 4. Release build (cargo build --release --workspace)
|
||||||
|
# 5. Artifact verification (binary size, permissions, SHA-256)
|
||||||
|
#
|
||||||
|
# PREREQUISITES:
|
||||||
|
# - Rust toolchain (stable)
|
||||||
|
# - Linux C build tools (libsqlite3 / pkg-config / ldd)
|
||||||
|
#
|
||||||
|
# BEHAVIOR:
|
||||||
|
# - Non-destructive to existing deployments.
|
||||||
|
# - Does NOT run 'cargo clean' to prevent accidental removal of release artifacts.
|
||||||
|
# - Produces target/release/nx9-wg.
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# bash scripts/build-release.sh
|
||||||
|
# ./scripts/build-release.sh
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "\n\033[1;34m[RELEASE-BUILD]\033[0m \033[1;37m$*\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "\033[1;32m[PASS]\033[0m $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
log "1/4 Verifying code formatting..."
|
||||||
|
cargo fmt --all -- --check || error "Formatting verification failed."
|
||||||
|
success "Formatting verified."
|
||||||
|
|
||||||
|
log "2/4 Running compiler and Clippy checks..."
|
||||||
|
cargo check --workspace --all-targets || error "Cargo check failed."
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings || error "Clippy check failed."
|
||||||
|
success "Lint checks passed with 0 warnings."
|
||||||
|
|
||||||
|
log "3/4 Running full workspace test suite..."
|
||||||
|
cargo test --workspace --all-targets || error "Workspace test suite failed."
|
||||||
|
success "All unit and integration tests passed."
|
||||||
|
|
||||||
|
log "4/4 Compiling optimized release binary (cargo build --release --workspace)..."
|
||||||
|
cargo build --release --workspace || error "Release build failed."
|
||||||
|
|
||||||
|
TARGET_BIN="${ROOT_DIR}/target/release/nx9-wg"
|
||||||
|
|
||||||
|
if [[ ! -f "${TARGET_BIN}" ]]; then
|
||||||
|
error "Expected release binary not found at ${TARGET_BIN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "${TARGET_BIN}" ]]; then
|
||||||
|
error "Release binary is not executable at ${TARGET_BIN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BIN_SIZE="$(du -h "${TARGET_BIN}" | cut -f1)"
|
||||||
|
BIN_SHA="$(sha256sum "${TARGET_BIN}" | awk '{print $1}')"
|
||||||
|
BIN_DATE="$(date -r "${TARGET_BIN}" '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
|
||||||
|
echo -e "\n================================================================="
|
||||||
|
echo -e "\033[1;32m RELEASE BUILD SUCCESSFUL!\033[0m"
|
||||||
|
echo -e "================================================================="
|
||||||
|
echo " Binary Path: ${TARGET_BIN}"
|
||||||
|
echo " Binary Size: ${BIN_SIZE}"
|
||||||
|
echo " Timestamp: ${BIN_DATE}"
|
||||||
|
echo " SHA-256: ${BIN_SHA}"
|
||||||
|
echo " Version: $("${TARGET_BIN}" version | head -n 1)"
|
||||||
|
echo -e "=================================================================\n"
|
||||||
Executable
+216
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# nx9-wg Production Deployment Tool
|
||||||
|
# ==============================================================================
|
||||||
|
# PURPOSE:
|
||||||
|
# Deploys the compiled release binary target/release/nx9-wg to the production
|
||||||
|
# system path (/usr/local/bin/nx9-wg) with validated controlled replacement,
|
||||||
|
# automatic backup of the previous binary, and controlled systemd service management.
|
||||||
|
#
|
||||||
|
# PREREQUISITES:
|
||||||
|
# - Root privileges (or sudo)
|
||||||
|
# - Pre-compiled release binary at target/release/nx9-wg
|
||||||
|
# - Linux with systemd
|
||||||
|
#
|
||||||
|
# SAFETY INVARIANTS:
|
||||||
|
# - Never overwrites the existing production binary without creating a timestamped backup.
|
||||||
|
# - Never modifies or overwrites database files (/var/lib/nx9-wg/nx9-wg.db).
|
||||||
|
# - Never deletes WireGuard interfaces or kills processes automatically on port conflict.
|
||||||
|
# - Uses the standard 'install' command for controlled binary replacement and strict permissions.
|
||||||
|
# - Returns non-zero exit code on failure.
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# sudo bash scripts/deploy.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# OPTIONS:
|
||||||
|
# --no-restart Install binary without restarting nx9-wg.service
|
||||||
|
# --dry-run Simulate deployment actions without applying changes
|
||||||
|
# -h, --help Show this help message
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
SOURCE_BIN="${ROOT_DIR}/target/release/nx9-wg"
|
||||||
|
DEST_BIN="/usr/local/bin/nx9-wg"
|
||||||
|
CONF_DIR="/etc/nx9-wg"
|
||||||
|
DATA_DIR="/var/lib/nx9-wg"
|
||||||
|
BACKUP_DIR="${DATA_DIR}/backups"
|
||||||
|
LOG_DIR="/var/log/nx9-wg"
|
||||||
|
SERVICE_DEST="/etc/systemd/system/nx9-wg.service"
|
||||||
|
SERVICE_SRC="${ROOT_DIR}/nx9-wg.service"
|
||||||
|
|
||||||
|
DRY_RUN=0
|
||||||
|
NO_RESTART=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
nx9-wg Production Deployment Tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
sudo bash scripts/deploy.sh [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--no-restart Install binary without restarting nx9-wg.service
|
||||||
|
--dry-run Simulate deployment actions without applying changes
|
||||||
|
-h, --help Show this help message
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-restart)
|
||||||
|
NO_RESTART=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "\033[1;34m[DEPLOY]\033[0m \033[1;37m$*\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "\033[1;32m[SUCCESS]\033[0m $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
echo -e "\033[1;33m[WARN]\033[0m $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Privilege Verification
|
||||||
|
if [[ "${EUID}" -ne 0 && "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
error "Deployment must be run as root (or via sudo)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Source Binary Verification
|
||||||
|
if [[ ! -f "${SOURCE_BIN}" ]]; then
|
||||||
|
error "Source binary not found at ${SOURCE_BIN}. Run 'bash scripts/build-release.sh' first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "${SOURCE_BIN}" ]]; then
|
||||||
|
error "Source binary at ${SOURCE_BIN} is not executable."
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE_SIZE="$(du -h "${SOURCE_BIN}" | cut -f1)"
|
||||||
|
SOURCE_SHA="$(sha256sum "${SOURCE_BIN}" | awk '{print $1}')"
|
||||||
|
SOURCE_DATE="$(date -r "${SOURCE_BIN}" '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
|
||||||
|
log "Source binary verified:"
|
||||||
|
echo " Path: ${SOURCE_BIN}"
|
||||||
|
echo " Size: ${SOURCE_SIZE}"
|
||||||
|
echo " Timestamp: ${SOURCE_DATE}"
|
||||||
|
echo " SHA-256: ${SOURCE_SHA}"
|
||||||
|
|
||||||
|
# 3. Create Filesystem Layout with Strict Permissions
|
||||||
|
log "Ensuring directory permissions..."
|
||||||
|
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
install -d -m 0750 "${CONF_DIR}"
|
||||||
|
install -d -m 0700 "${DATA_DIR}"
|
||||||
|
install -d -m 0700 "${BACKUP_DIR}"
|
||||||
|
install -d -m 0750 "${LOG_DIR}"
|
||||||
|
else
|
||||||
|
echo " [DRY-RUN] install -d directories: ${CONF_DIR}, ${DATA_DIR}, ${BACKUP_DIR}, ${LOG_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Backup Existing Production Binary
|
||||||
|
if [[ -f "${DEST_BIN}" ]]; then
|
||||||
|
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
BACKUP_DEST="${DEST_BIN}.backup.${TIMESTAMP}"
|
||||||
|
log "Backing up active binary to ${BACKUP_DEST}..."
|
||||||
|
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
cp -p "${DEST_BIN}" "${BACKUP_DEST}"
|
||||||
|
chmod 0755 "${BACKUP_DEST}"
|
||||||
|
success "Backup created: ${BACKUP_DEST}"
|
||||||
|
else
|
||||||
|
echo " [DRY-RUN] cp -p ${DEST_BIN} ${BACKUP_DEST}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Controlled Installation of New Binary
|
||||||
|
log "Installing new release binary to ${DEST_BIN}..."
|
||||||
|
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
install -m 0755 "${SOURCE_BIN}" "${DEST_BIN}"
|
||||||
|
success "Binary installed to ${DEST_BIN}"
|
||||||
|
else
|
||||||
|
echo " [DRY-RUN] install -m 0755 ${SOURCE_BIN} ${DEST_BIN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Install or Update systemd Service Unit
|
||||||
|
if [[ -f "${SERVICE_SRC}" && -d "/etc/systemd/system" ]]; then
|
||||||
|
log "Installing/updating systemd service unit..."
|
||||||
|
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
install -m 0644 "${SERVICE_SRC}" "${SERVICE_DEST}"
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
success "systemd service unit updated at ${SERVICE_DEST}"
|
||||||
|
else
|
||||||
|
echo " [DRY-RUN] install -m 0644 ${SERVICE_SRC} ${SERVICE_DEST}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Safe Socket Inspection
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
log "Inspecting active UDP listen sockets without modifying the host..."
|
||||||
|
ACTIVE_UDP_SOCKETS="$(ss -lunp 2>/dev/null | grep -v '^State' || true)"
|
||||||
|
if [[ -n "${ACTIVE_UDP_SOCKETS}" ]]; then
|
||||||
|
echo "${ACTIVE_UDP_SOCKETS}"
|
||||||
|
else
|
||||||
|
echo " No UDP listeners reported by ss."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8. Service Restart & Verification
|
||||||
|
if [[ "${NO_RESTART}" -eq 0 && "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
log "Restarting nx9-wg.service..."
|
||||||
|
systemctl restart nx9-wg || error "Failed to restart nx9-wg service."
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if systemctl is-active --quiet nx9-wg; then
|
||||||
|
success "nx9-wg.service is active and running."
|
||||||
|
else
|
||||||
|
warn "nx9-wg.service is not in active state. Inspecting journal..."
|
||||||
|
journalctl -u nx9-wg -n 20 --no-pager || true
|
||||||
|
error "Service failed to start."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [[ "${NO_RESTART}" -eq 1 ]]; then
|
||||||
|
log "Skipping service restart as requested (--no-restart)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 9. Final Deployment Verification
|
||||||
|
log "Verifying deployed binary version..."
|
||||||
|
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
DEPLOYED_VER="$("${DEST_BIN}" version | head -n 1)"
|
||||||
|
success "Deployed binary active: ${DEPLOYED_VER}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n================================================================="
|
||||||
|
echo -e "\033[1;32m DEPLOYMENT COMPLETED SUCCESSFULLY!\033[0m"
|
||||||
|
echo -e "================================================================="
|
||||||
|
echo " Installed Binary: ${DEST_BIN}"
|
||||||
|
echo " Configuration: ${CONF_DIR}/config.toml"
|
||||||
|
echo " Database: ${DATA_DIR}/nx9-wg.db"
|
||||||
|
echo " Service Status: systemctl status nx9-wg"
|
||||||
|
echo -e "=================================================================\n"
|
||||||
Executable
+155
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# nx9-wg Production Diagnostic Collector
|
||||||
|
# ==============================================================================
|
||||||
|
# PURPOSE:
|
||||||
|
# Collects a comprehensive, non-destructive diagnostic snapshot across both
|
||||||
|
# desired SQLite state and live Linux kernel networking state:
|
||||||
|
# - Systemd service & journal log health
|
||||||
|
# - UDP socket bindings & conflict inspection
|
||||||
|
# - Kernel IP link, address, and routing status
|
||||||
|
# - Kernel WireGuard link/interface and peer telemetry (via secret-safe 'wg show')
|
||||||
|
# - Netfilter / nftables ruleset in 'table inet nx9_wg'
|
||||||
|
# - Linux sysctl IP packet forwarding
|
||||||
|
# - Application-level diagnostic subsystem inspections
|
||||||
|
#
|
||||||
|
# PREREQUISITES:
|
||||||
|
# - Root privileges (recommended for kernel/socket inspection, or run via sudo)
|
||||||
|
#
|
||||||
|
# SECURITY INVARIANTS:
|
||||||
|
# - NEVER calls 'wg showconf' (which prints private keys in cleartext).
|
||||||
|
# - Relies exclusively on 'wg show' which masks private keys.
|
||||||
|
# - Strictly non-destructive: only performs read-only inspections.
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# sudo bash scripts/diagnose.sh
|
||||||
|
# ./scripts/diagnose.sh
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
BIN="/usr/local/bin/nx9-wg"
|
||||||
|
if [[ ! -x "${BIN}" ]]; then
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
if [[ -x "${ROOT_DIR}/target/release/nx9-wg" ]]; then
|
||||||
|
BIN="${ROOT_DIR}/target/release/nx9-wg"
|
||||||
|
elif [[ -x "${ROOT_DIR}/target/debug/nx9-wg" ]]; then
|
||||||
|
BIN="${ROOT_DIR}/target/debug/nx9-wg"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
section() {
|
||||||
|
echo -e "\n================================================================="
|
||||||
|
echo -e "\033[1;36m>> $*\033[0m"
|
||||||
|
echo -e "================================================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
subsection() {
|
||||||
|
echo -e "\n\033[1;33m--- $*\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
section "1. System & Host Runtime Environment"
|
||||||
|
echo "Timestamp: $(date --iso-8601=seconds)"
|
||||||
|
echo "Hostname: $(hostname)"
|
||||||
|
echo "Kernel: $(uname -r)"
|
||||||
|
echo "Architecture: $(uname -m)"
|
||||||
|
echo "Uptime: $(uptime -p 2>/dev/null || uptime)"
|
||||||
|
if [[ -x "${BIN}" ]]; then
|
||||||
|
echo "nx9-wg: $("${BIN}" version | head -n 1)"
|
||||||
|
else
|
||||||
|
echo "nx9-wg: Binary not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "2. Systemd Service & Process State"
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
subsection "Service Status (nx9-wg.service)"
|
||||||
|
systemctl status nx9-wg --no-pager -l || true
|
||||||
|
|
||||||
|
subsection "Recent Journalctl Logs (Last 30 entries)"
|
||||||
|
journalctl -u nx9-wg -n 30 --no-pager || true
|
||||||
|
else
|
||||||
|
echo "systemctl not available on this host."
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "3. UDP Sockets & Listen Port Inspection"
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
subsection "Active UDP Listen Sockets (ss -lunp)"
|
||||||
|
ss -lunp 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "ss utility not found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "4. Linux Network Interfaces & Addresses"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
subsection "Brief Interface State (ip -br link)"
|
||||||
|
ip -br link show || true
|
||||||
|
|
||||||
|
subsection "Brief IPv4 / IPv6 Addresses (ip -br addr)"
|
||||||
|
ip -br addr show || true
|
||||||
|
|
||||||
|
subsection "WireGuard Interface Addresses"
|
||||||
|
if command -v wg >/dev/null 2>&1; then
|
||||||
|
WG_INTERFACES="$(wg show interfaces 2>/dev/null || true)"
|
||||||
|
if [[ -n "${WG_INTERFACES}" ]]; then
|
||||||
|
for WG_IFACE in ${WG_INTERFACES}; do
|
||||||
|
echo "Interface: ${WG_IFACE}"
|
||||||
|
ip addr show dev "${WG_IFACE}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No WireGuard interfaces reported by the kernel."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "wg utility not found; WireGuard interface-specific address inspection skipped."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ip utility not found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "5. Kernel Routing Table"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
subsection "IPv4 Routes (ip route show)"
|
||||||
|
ip route show || true
|
||||||
|
|
||||||
|
subsection "IPv6 Routes (ip -6 route show)"
|
||||||
|
ip -6 route show || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "6. Kernel WireGuard Telemetry (Secret-Safe 'wg show')"
|
||||||
|
if command -v wg >/dev/null 2>&1; then
|
||||||
|
wg show 2>&1 || echo "wg show returned non-zero (may require root privileges)."
|
||||||
|
else
|
||||||
|
echo "wg utility not found on host."
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "7. Netfilter / nftables Firewall State (table inet nx9_wg)"
|
||||||
|
if command -v nft >/dev/null 2>&1; then
|
||||||
|
nft list table inet nx9_wg 2>/dev/null || echo "nftables table 'inet nx9_wg' not present."
|
||||||
|
else
|
||||||
|
echo "nft utility not found on host."
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "8. IP Packet Forwarding (Kernel Sysctl)"
|
||||||
|
echo -n "net.ipv4.ip_forward: "
|
||||||
|
cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo "Unable to read /proc/sys/net/ipv4/ip_forward"
|
||||||
|
echo -n "net.ipv6.conf.all.forwarding: "
|
||||||
|
cat /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || echo "Unable to read /proc/sys/net/ipv6/conf/all/forwarding"
|
||||||
|
|
||||||
|
section "9. Application Desired State & Health Checks"
|
||||||
|
if [[ -x "${BIN}" ]]; then
|
||||||
|
subsection "Appliance Health Check"
|
||||||
|
"${BIN}" system health 2>&1 || true
|
||||||
|
|
||||||
|
subsection "All Subsystems Diagnostics"
|
||||||
|
"${BIN}" diagnostics all 2>&1 || true
|
||||||
|
|
||||||
|
subsection "Reconciliation Drift Status"
|
||||||
|
"${BIN}" reconcile status 2>&1 || true
|
||||||
|
|
||||||
|
subsection "Live WireGuard Interface Status"
|
||||||
|
"${BIN}" live interface list 2>&1 || true
|
||||||
|
else
|
||||||
|
echo "nx9-wg binary not executable; skipping application-level diagnostics."
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "Diagnostic Collection Complete"
|
||||||
Regular → Executable
+1
-1
@@ -161,7 +161,7 @@ fi
|
|||||||
if [[ "${NO_INIT}" -eq 0 && "${DRY_RUN}" -eq 0 ]]; then
|
if [[ "${NO_INIT}" -eq 0 && "${DRY_RUN}" -eq 0 ]]; then
|
||||||
PW_FILE="${DATA_DIR}/admin-initial-password"
|
PW_FILE="${DATA_DIR}/admin-initial-password"
|
||||||
log "Checking administrator account initialization..."
|
log "Checking administrator account initialization..."
|
||||||
if "${BIN_DIR}/nx9-wg" --config "${CONF_DIR}/config.toml" --data-dir "${DATA_DIR}" admin info >/dev/null 2>&1; then
|
if "${BIN_DIR}/nx9-wg" --config "${CONF_DIR}/config.toml" --data-dir "${DATA_DIR}" admin status >/dev/null 2>&1; then
|
||||||
log "Administrator account already initialized in database."
|
log "Administrator account already initialized in database."
|
||||||
else
|
else
|
||||||
log "Initializing administrator account with secure random credentials..."
|
log "Initializing administrator account with secure random credentials..."
|
||||||
|
|||||||
Regular → Executable
File mode changed.
Executable
+174
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# nx9-wg Production Rollback Tool
|
||||||
|
# ==============================================================================
|
||||||
|
# PURPOSE:
|
||||||
|
# Rolls back the active production binary (/usr/local/bin/nx9-wg) to the most
|
||||||
|
# recent (or specified) backup binary created during previous deployments.
|
||||||
|
#
|
||||||
|
# PREREQUISITES:
|
||||||
|
# - Root privileges (or sudo)
|
||||||
|
# - At least one backup binary at /usr/local/bin/nx9-wg.backup.*
|
||||||
|
#
|
||||||
|
# SAFETY INVARIANTS:
|
||||||
|
# - Never modifies or deletes the SQLite database.
|
||||||
|
# - Restores executable permissions (0755) and root ownership.
|
||||||
|
# - Confirms service restoration and binary version after rollback.
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# sudo bash scripts/rollback.sh [OPTIONS] [SPECIFIC_BACKUP_PATH]
|
||||||
|
#
|
||||||
|
# OPTIONS:
|
||||||
|
# -y, --yes Skip confirmation prompt
|
||||||
|
# -l, --list List available backup binaries and exit
|
||||||
|
# -h, --help Show this help message
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BIN_DIR="/usr/local/bin"
|
||||||
|
ACTIVE_BIN="${BIN_DIR}/nx9-wg"
|
||||||
|
ASSUME_YES=0
|
||||||
|
LIST_ONLY=0
|
||||||
|
SPECIFIED_BACKUP=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
nx9-wg Production Rollback Tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
sudo bash scripts/rollback.sh [OPTIONS] [BACKUP_FILE]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-y, --yes Skip interactive confirmation prompt
|
||||||
|
-l, --list List available backup binaries and exit
|
||||||
|
-h, --help Show this help message
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-y|--yes)
|
||||||
|
ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-l|--list)
|
||||||
|
LIST_ONLY=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
SPECIFIED_BACKUP="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "\033[1;34m[ROLLBACK]\033[0m \033[1;37m$*\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "\033[1;32m[SUCCESS]\033[0m $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
echo -e "\033[1;33m[WARN]\033[0m $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Privilege Verification (unless list only)
|
||||||
|
if [[ "${EUID}" -ne 0 && "${LIST_ONLY}" -eq 0 ]]; then
|
||||||
|
error "Rollback must be run as root (or via sudo)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Discover Available Backups
|
||||||
|
BACKUPS=($(ls -1t "${ACTIVE_BIN}".backup.* 2>/dev/null || true))
|
||||||
|
|
||||||
|
if [[ "${#BACKUPS[@]}" -eq 0 ]]; then
|
||||||
|
error "No backup binaries found in ${BIN_DIR} matching nx9-wg.backup.*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${LIST_ONLY}" -eq 1 ]]; then
|
||||||
|
echo "Available production backup binaries in ${BIN_DIR}:"
|
||||||
|
for b in "${BACKUPS[@]}"; do
|
||||||
|
SIZE="$(du -h "$b" | cut -f1)"
|
||||||
|
DATE="$(date -r "$b" '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo " $b (${SIZE}, ${DATE})"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Select Target Backup
|
||||||
|
TARGET_BACKUP=""
|
||||||
|
if [[ -n "${SPECIFIED_BACKUP}" ]]; then
|
||||||
|
if [[ -f "${SPECIFIED_BACKUP}" ]]; then
|
||||||
|
TARGET_BACKUP="${SPECIFIED_BACKUP}"
|
||||||
|
elif [[ -f "${BIN_DIR}/${SPECIFIED_BACKUP}" ]]; then
|
||||||
|
TARGET_BACKUP="${BIN_DIR}/${SPECIFIED_BACKUP}"
|
||||||
|
else
|
||||||
|
error "Specified backup file not found: ${SPECIFIED_BACKUP}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
TARGET_BACKUP="${BACKUPS[0]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Selected rollback target: ${TARGET_BACKUP}"
|
||||||
|
BACKUP_SIZE="$(du -h "${TARGET_BACKUP}" | cut -f1)"
|
||||||
|
BACKUP_DATE="$(date -r "${TARGET_BACKUP}" '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo " Size: ${BACKUP_SIZE}"
|
||||||
|
echo " Timestamp: ${BACKUP_DATE}"
|
||||||
|
|
||||||
|
# 4. Confirmation Prompt
|
||||||
|
if [[ "${ASSUME_YES}" -eq 0 ]]; then
|
||||||
|
echo -e "\n\033[1;33mAre you sure you want to replace active binary ${ACTIVE_BIN} with ${TARGET_BACKUP}?\033[0m"
|
||||||
|
read -r -p "Type 'yes' to proceed with rollback: " CONFIRM
|
||||||
|
if [[ "${CONFIRM}" != "yes" ]]; then
|
||||||
|
error "Rollback aborted by user."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Execute Rollback
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
if systemctl is-active --quiet nx9-wg 2>/dev/null; then
|
||||||
|
log "Stopping nx9-wg service..."
|
||||||
|
systemctl stop nx9-wg || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Restoring binary from ${TARGET_BACKUP} to ${ACTIVE_BIN}..."
|
||||||
|
install -m 0755 "${TARGET_BACKUP}" "${ACTIVE_BIN}"
|
||||||
|
|
||||||
|
# 6. Restart Service
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
log "Starting nx9-wg service..."
|
||||||
|
systemctl start nx9-wg || error "Failed to restart nx9-wg service after rollback."
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if systemctl is-active --quiet nx9-wg; then
|
||||||
|
success "nx9-wg.service is active and running."
|
||||||
|
else
|
||||||
|
warn "nx9-wg.service is not active. Checking logs:"
|
||||||
|
journalctl -u nx9-wg -n 20 --no-pager || true
|
||||||
|
error "Service failed to become active after rollback."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Verify Restored Version
|
||||||
|
RESTORED_VER="$("${ACTIVE_BIN}" version | head -n 1)"
|
||||||
|
success "Rollback successful. Active binary version: ${RESTORED_VER}"
|
||||||
|
|
||||||
|
echo -e "\n================================================================="
|
||||||
|
echo -e "\033[1;32m PRODUCTION ROLLBACK COMPLETED SUCCESSFULLY!\033[0m"
|
||||||
|
echo -e "=================================================================\n"
|
||||||
@@ -154,9 +154,14 @@ log_pass "Pre-flight baseline captured in ${BASELINE_DIR}"
|
|||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
section "02 — SQLite Store Initialization"
|
section "02 — SQLite Store Initialization"
|
||||||
|
|
||||||
|
TEMP_ADMIN_PW="$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9!@#%^&*_-' | head -c 20)"
|
||||||
|
PW_FILE="${TEST_ROOT}/admin_pw.txt"
|
||||||
|
echo -n "${TEMP_ADMIN_PW}" > "${PW_FILE}"
|
||||||
|
chmod 0600 "${PW_FILE}"
|
||||||
|
|
||||||
"${BIN}" --data-dir "${DATA_DIR}" --database "${DB_PATH}" init \
|
"${BIN}" --data-dir "${DATA_DIR}" --database "${DB_PATH}" init \
|
||||||
--username "admin" \
|
--username "admin" \
|
||||||
--password "AdminPassword123!" >/dev/null
|
--password-file "${PW_FILE}" >/dev/null
|
||||||
|
|
||||||
if [[ -f "${DB_PATH}" ]]; then
|
if [[ -f "${DB_PATH}" ]]; then
|
||||||
log_pass "SQLite authoritative store created and migrated"
|
log_pass "SQLite authoritative store created and migrated"
|
||||||
@@ -288,7 +293,7 @@ all_dumps="${TEST_ROOT}/all_dumps.txt"
|
|||||||
"${BIN}" --data-dir "${DATA_DIR}" --database "${DB_PATH}" --json reconcile plan >> "${all_dumps}" 2>&1 || true
|
"${BIN}" --data-dir "${DATA_DIR}" --database "${DB_PATH}" --json reconcile plan >> "${all_dumps}" 2>&1 || true
|
||||||
"${BIN}" --data-dir "${DATA_DIR}" --database "${DB_PATH}" --json diagnostics all >> "${all_dumps}" 2>&1 || true
|
"${BIN}" --data-dir "${DATA_DIR}" --database "${DB_PATH}" --json diagnostics all >> "${all_dumps}" 2>&1 || true
|
||||||
|
|
||||||
if grep -q "AdminPassword123!" "${all_dumps}"; then
|
if grep -F -q "${TEMP_ADMIN_PW}" "${all_dumps}"; then
|
||||||
log_fail "Plaintext administrator password found in command output"
|
log_fail "Plaintext administrator password found in command output"
|
||||||
else
|
else
|
||||||
log_pass "Zero plaintext passwords leaked in CLI/diagnostics output"
|
log_pass "Zero plaintext passwords leaked in CLI/diagnostics output"
|
||||||
|
|||||||
Regular → Executable
-1
@@ -119,4 +119,3 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "nx9-wg uninstalled successfully."
|
log "nx9-wg uninstalled successfully."
|
||||||
EOF
|
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# nx9-wg Development Verification Gate
|
||||||
|
# ==============================================================================
|
||||||
|
# PURPOSE:
|
||||||
|
# Executes the full development and release quality gate suite in sequence:
|
||||||
|
# 1. Formatting verification (cargo fmt --all -- --check)
|
||||||
|
# 2. Workspace compilation (cargo check --workspace --all-targets)
|
||||||
|
# 3. Strict Clippy linting (cargo clippy --workspace --all-targets --all-features -- -D warnings)
|
||||||
|
# 4. Complete workspace unit & integration tests (cargo test --workspace --all-targets)
|
||||||
|
# 5. Comprehensive CLI verification (scripts/test-cli-comprehensive.sh)
|
||||||
|
#
|
||||||
|
# PREREQUISITES:
|
||||||
|
# - Rust toolchain (cargo, rustc, rustfmt, clippy)
|
||||||
|
#
|
||||||
|
# BEHAVIOR:
|
||||||
|
# - 100% Non-destructive.
|
||||||
|
# - Returns exit code 0 if all checks pass.
|
||||||
|
# - Returns non-zero exit code immediately on any failure.
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# bash scripts/verify.sh
|
||||||
|
# ./scripts/verify.sh
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "\n\033[1;34m[VERIFY]\033[0m \033[1;37m$*\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "\033[1;32m[PASS]\033[0m $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "\033[1;31m[FAIL]\033[0m $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
log "1/5 Checking code formatting..."
|
||||||
|
if cargo fmt --all -- --check; then
|
||||||
|
success "Code formatting is clean."
|
||||||
|
else
|
||||||
|
error "Formatting check failed. Run 'cargo fmt --all' to fix."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "2/5 Compiling workspace targets..."
|
||||||
|
if cargo check --workspace --all-targets; then
|
||||||
|
success "Workspace compilation check passed."
|
||||||
|
else
|
||||||
|
error "Compilation check failed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "3/5 Running Clippy with -D warnings..."
|
||||||
|
if cargo clippy --workspace --all-targets --all-features -- -D warnings; then
|
||||||
|
success "Clippy linting passed with 0 warnings."
|
||||||
|
else
|
||||||
|
error "Clippy check failed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "4/5 Running complete workspace test suite..."
|
||||||
|
if cargo test --workspace --all-targets; then
|
||||||
|
success "All workspace unit and integration tests passed."
|
||||||
|
else
|
||||||
|
error "Workspace test suite failed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "5/5 Running comprehensive CLI verification..."
|
||||||
|
if bash "${SCRIPT_DIR}/test-cli-comprehensive.sh"; then
|
||||||
|
success "CLI comprehensive verification suite passed."
|
||||||
|
else
|
||||||
|
error "CLI verification failed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n================================================================="
|
||||||
|
echo -e "\033[1;32m ALL VERIFICATION QUALITY GATES PASSED SUCCESSFULLY!\033[0m"
|
||||||
|
echo -e "=================================================================\n"
|
||||||
+45
-19
@@ -1351,7 +1351,48 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsSubcommands::Set { key, value, secret } => {
|
SettingsSubcommands::Set { key, value, secret } => {
|
||||||
store.set_setting(&key, &value, secret).await?;
|
let key_trimmed = key.trim();
|
||||||
|
let val_trimmed = value.trim();
|
||||||
|
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)?;
|
||||||
|
}
|
||||||
|
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_PORT {
|
||||||
|
let port: u16 = val_trimmed.parse().map_err(
|
||||||
|
|_| "Invalid server port: must be an integer between 1 and 65535",
|
||||||
|
)?;
|
||||||
|
nx9_wg_core::validation::validate_server_port(port)?;
|
||||||
|
} 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("Setting wireguard.server_endpoint_enabled must be 'true' or 'false'".into());
|
||||||
|
}
|
||||||
|
store.set_setting(key_trimmed, val_trimmed, secret).await?;
|
||||||
|
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) = 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 _ = store
|
||||||
|
.set_setting(
|
||||||
|
nx9_wg_core::types::settings::LEGACY_SETTING_SERVER_ENDPOINT,
|
||||||
|
&formatted,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
println!("Setting '{key}' saved.");
|
println!("Setting '{key}' saved.");
|
||||||
}
|
}
|
||||||
SettingsSubcommands::Delete { key } => {
|
SettingsSubcommands::Delete { key } => {
|
||||||
@@ -2089,15 +2130,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let server_host = if let Some(ref ep) = endpoint {
|
let server_host = store.resolve_server_endpoint(endpoint.as_deref()).await?;
|
||||||
ep.trim().to_string()
|
|
||||||
} else if let Some(s) = store.get_setting("server_endpoint").await? {
|
|
||||||
s.value.trim().to_string()
|
|
||||||
} else if let Some(s) = store.get_setting("public_endpoint").await? {
|
|
||||||
s.value.trim().to_string()
|
|
||||||
} else {
|
|
||||||
return Err("No reachable WireGuard server endpoint is configured. Configure 'server_endpoint' in settings or provide --endpoint.".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let conf = ClientConfigBuilder::build_with_profile(
|
let conf = ClientConfigBuilder::build_with_profile(
|
||||||
&peer,
|
&peer,
|
||||||
@@ -2173,15 +2206,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let server_host = if let Some(ref ep) = endpoint {
|
let server_host = store.resolve_server_endpoint(endpoint.as_deref()).await?;
|
||||||
ep.trim().to_string()
|
|
||||||
} else if let Some(s) = store.get_setting("server_endpoint").await? {
|
|
||||||
s.value.trim().to_string()
|
|
||||||
} else if let Some(s) = store.get_setting("public_endpoint").await? {
|
|
||||||
s.value.trim().to_string()
|
|
||||||
} else {
|
|
||||||
return Err("No reachable WireGuard server endpoint is configured. Configure 'server_endpoint' in settings or provide --endpoint.".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let conf = ClientConfigBuilder::build_with_profile(
|
let conf = ClientConfigBuilder::build_with_profile(
|
||||||
&peer,
|
&peer,
|
||||||
@@ -2189,6 +2214,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
&server_host,
|
&server_host,
|
||||||
resolved_profile.as_ref(),
|
resolved_profile.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
match qr_format.to_lowercase().as_str() {
|
match qr_format.to_lowercase().as_str() {
|
||||||
"svg" => {
|
"svg" => {
|
||||||
let svg = generate_qr_svg(&conf)?;
|
let svg = generate_qr_svg(&conf)?;
|
||||||
|
|||||||
@@ -240,10 +240,6 @@ fn test_cli_interface_and_peer_lifecycle() {
|
|||||||
let ifaces: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
let ifaces: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||||
assert_eq!(ifaces.as_array().unwrap().len(), 1);
|
assert_eq!(ifaces.as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
// Status
|
|
||||||
let (ok, _, _) = runner.run(&["interface", "status", "wg0"]);
|
|
||||||
assert!(ok);
|
|
||||||
|
|
||||||
// Create Peer
|
// Create Peer
|
||||||
let (ok, out, _) = runner.run(&[
|
let (ok, out, _) = runner.run(&[
|
||||||
"peer",
|
"peer",
|
||||||
@@ -829,6 +825,40 @@ fn test_cli_client_profile_and_mtu_system() {
|
|||||||
]);
|
]);
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
assert!(out.contains("<svg"));
|
assert!(out.contains("<svg"));
|
||||||
|
|
||||||
|
// 8. Set structured WireGuard server endpoint settings
|
||||||
|
let (ok, _, _) = runner.run(&[
|
||||||
|
"system",
|
||||||
|
"settings",
|
||||||
|
"set",
|
||||||
|
"wireguard.server_host",
|
||||||
|
"vpn.thakares.com",
|
||||||
|
]);
|
||||||
|
assert!(ok);
|
||||||
|
let (ok, _, _) = runner.run(&[
|
||||||
|
"system",
|
||||||
|
"settings",
|
||||||
|
"set",
|
||||||
|
"wireguard.server_port",
|
||||||
|
"51820",
|
||||||
|
]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// 9. Peer Config consumes persistent structured setting
|
||||||
|
let (ok, out, _) = runner.run(&["peer", "config", peer_id]);
|
||||||
|
assert!(ok, "peer config failed: {out}");
|
||||||
|
assert!(out.contains("Endpoint = vpn.thakares.com:51820"));
|
||||||
|
|
||||||
|
// 10. CLI explicit --endpoint overrides persistent setting
|
||||||
|
let (ok, out, _) = runner.run(&[
|
||||||
|
"peer",
|
||||||
|
"config",
|
||||||
|
peer_id,
|
||||||
|
"--endpoint",
|
||||||
|
"custom.override.io:51820",
|
||||||
|
]);
|
||||||
|
assert!(ok, "peer config with explicit override failed: {out}");
|
||||||
|
assert!(out.contains("Endpoint = custom.override.io:51820"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user