Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f2f04226 | ||
|
|
edc710cbd2 | ||
|
|
34227efd2b | ||
|
|
5599e1b5c8 | ||
|
|
32a325234a | ||
|
|
d25846c58c | ||
|
|
2f06c8faa9 | ||
|
|
d710deb8b0 | ||
|
|
a6208ef330 | ||
|
|
d704c1e131 |
No files matched your search
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to **NX9-WG (`nx9-wg`)** are documented here.
|
||||
|
||||
## [1.1.0] — 2026-09-02
|
||||
|
||||
### Added
|
||||
- **Interface Roles**: Explicit `InterfaceRole` discriminator (`Overlay` vs `Upstream`). Primary interface `wg0` is protected from deletion and disabling.
|
||||
- **Optional Third-Party Upstream Interfaces**: In-process parser and validator for standard third-party WireGuard `.conf` files (validated against ProtonVPN), creating managed `Upstream` interfaces (e.g. `proton0`) with exactly one provider peer.
|
||||
- **REST API Endpoints**: Added `POST /api/v1/interfaces/upstreams/preview` (dry-run configuration validation with secret redaction), `POST /api/v1/interfaces/upstreams/import` (atomic SQLite persistence and reconciliation), and `POST /api/v1/interfaces/{id}/restart` (link teardown and re-synchronization).
|
||||
- **Native CLI Commands**: Added `nx9-wg interface upstream` command suite (`list`, `show`, `import`, `status`, `enable`, `disable`, `restart`, `delete`) and `nx9-wg interface restart`.
|
||||
- **Read-Only SPA CLI Console**: Embedded web-based CLI runner enforcing a strict read-only command allowlist and output secret scrubbing.
|
||||
- **Reconciliation Hardening**: Added orphan kernel interface detection and removal during `apply()`, backed by empty-desired-state safety guards preventing destructive cleanup on database read failures.
|
||||
- **Provider AllowedIPs Preservation**: Upstream provider peers retain full-tunnel AllowedIPs (`0.0.0.0/0, ::/0`) in WireGuard Cryptokey Routing without modifying or hijacking host Linux FIB default routes.
|
||||
|
||||
### Changed
|
||||
- **Optional Local Listen Ports**: Changed `Interface.listen_port` to `Option<u16>` across domain models, Netlink device configuration, REST API, and SQLite database (`0005_optional_listen_port.sql`).
|
||||
- **Dynamic Port Web UI**: Unspecified listen ports are rendered as `Auto (Dynamic)` rather than a fabricated `51820`.
|
||||
|
||||
### Fixed
|
||||
- **Local Listen Port Collision (errno=-98 / EADDRINUSE)**: Fixed upstream interfaces defaulting omitted `ListenPort` to `51820`, which collided with `wg0`. Omitted listen ports now remain `None`, allowing Linux WireGuard to bind an ephemeral dynamic UDP port.
|
||||
- **Reconciliation Dynamic Port Drift**: Suppressed false listen-port drift when desired `listen_port` is `None` and the kernel reports a dynamic port.
|
||||
- **Provider Endpoint Port Independence**: Ensured remote destination `[Peer] Endpoint` port (e.g. `37.19.199.155:51820`) is strictly preserved and never assigned as the local interface listen port.
|
||||
|
||||
### Interoperability Status
|
||||
- **ProtonVPN**: ProtonVPN WireGuard `.conf` files import and synchronize cleanly into Linux kernel devices (`proton0`) with dynamic local listen ports. Upstream connectivity status is classified as `interop_pending_external_validation` (pending external provider session/endpoint resolution, not an NX9-WG implementation defect).
|
||||
|
||||
## [1.0.0] — 2026-08-18
|
||||
|
||||
NX9-WG 1.0.0 is the first production release of the native Linux WireGuard + network control plane.
|
||||
|
||||
@@ -1785,7 +1785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-wg"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
@@ -1807,7 +1807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-wg-api"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -1832,7 +1832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-wg-core"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64",
|
||||
@@ -1852,7 +1852,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-wg-db"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ipnet",
|
||||
@@ -1869,7 +1869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-wg-network"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -1890,7 +1890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-wg-ui"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"nx9-wg-core",
|
||||
@@ -1901,7 +1901,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-wireguard"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
|
||||
@@ -10,7 +10,7 @@ members = [
|
||||
|
||||
[workspace.package]
|
||||
license = "MIT OR Apache-2.0"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
edition = "2024"
|
||||
authors = ["NX9 Authors <team@nx9.in>"]
|
||||
repository = "https://github.com/thakares/nx9-wg"
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
# NX9 WireGuard (`nx9-wg`) — Full Technical Architecture & Stack Report
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> **"Software people can own, understand, and control."**
|
||||
> — [NX9 Systems (https://nx9.in)](https://nx9.in)
|
||||
|
||||
---
|
||||
|
||||
## 📑 Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [The NX9 Philosophy in Implementation](#2-the-nx9-philosophy-in-implementation)
|
||||
3. [The Architectural Paradigm Shift](#3-the-architectural-paradigm-shift)
|
||||
4. [Six-Crate Workspace Architecture](#4-six-crate-workspace-architecture)
|
||||
5. [Complete Capability Inventory](#5-complete-capability-inventory)
|
||||
6. [Native Linux Execution Planes](#6-native-linux-execution-planes)
|
||||
7. [Closed-Loop Reconciliation & Convergence](#7-closed-loop-reconciliation--convergence)
|
||||
8. [Embedded Single Page Application (SPA) & WebSockets](#8-embedded-single-page-application-spa--websockets)
|
||||
9. [REST API & WebSocket Protocol Reference](#9-rest-api--websocket-protocol-reference)
|
||||
10. [Native CLI Command System](#10-native-cli-command-system)
|
||||
11. [Security Model & Capability Isolation](#11-security-model--capability-isolation)
|
||||
12. [Disaster Recovery, Backups & Upgrades](#12-disaster-recovery-backups--upgrades)
|
||||
13. [Release Engineering & Deployment Lifecycle](#13-release-engineering--deployment-lifecycle)
|
||||
14. [Quality Assurance & Verification Evidence](#14-quality-assurance--verification-evidence)
|
||||
15. [Ecosystem & License Summary](#15-ecosystem--license-summary)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
`nx9-wg` is a sovereign, self-hosted, Linux-native VPN and network control plane built directly around the Linux kernel's in-tree WireGuard implementation (`wireguard.ko`).
|
||||
|
||||
Rather than functioning as a fragile user interface wrapper that shells out to external command-line utilities (`wg`, `ip`, `nft`, `sysctl`), `nx9-wg` establishes an integrated, single-binary architecture. It combines **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**.
|
||||
|
||||
---
|
||||
|
||||
## 2. The NX9 Philosophy in Implementation
|
||||
|
||||
Every design and architectural choice in `nx9-wg` directly reflects the core philosophy of the **NX9 Ecosystem** ([https://nx9.in](https://nx9.in)):
|
||||
|
||||
| NX9 Principle | Core Intent | `nx9-wg` Implementation |
|
||||
| :--- | :--- | :--- |
|
||||
| 🔑 **Operator Ownership** | Full control over binaries, configurations, databases, keys, and backups with zero dependency on cloud-hosted control planes. | 100% local operation. Private keys, configuration data, and encryption parameters never leave the operator's host. |
|
||||
| 🖥️ **Self-Hosting First** | Built to be deployed and operated effortlessly by a single administrator without requiring Kubernetes or external infrastructure. | Self-contained single executable. Bootstrapped with a single command (`nx9-wg init`), running seamlessly under standard systemd. |
|
||||
| ⚡ **Simplicity Over Complexity** | One binary, one configuration file, one SQLite database, one administrator. | No multi-daemon orchestration, no external message queues, no Node.js runtime, no Python scripts, and zero shell wrappers. |
|
||||
| 🛡️ **Privacy by Default** | Zero analytics, zero telemetry collection, zero third-party tracking, and zero assumptions of external cloud connectivity. | No phone-home telemetry. Completely air-gapped capable with all static assets, fonts, and scripts embedded in the binary. |
|
||||
| 🔒 **Security by Design** | Modern cryptography, strict invariants, and append-only audit logging embedded from day one. | Argon2id password hashing, SHA-256 token digests, X25519 key generation, single-admin database constraint (`CHECK (id=1)`), and strict secret redaction. |
|
||||
| 🔧 **Operational Excellence** | Diagnostics, backup/restore, migration tools, CLI management, and comprehensive documentation built-in. | Multi-subsystem automated health inspections, atomic online SQLite `VACUUM INTO` snapshots with SHA-256 manifests, and 100% CLI parity. |
|
||||
| ⏳ **Long-Term Stability** | Avoid dependency churn. Build software that remains understandable and maintainable years into the future. | Built in stable native Rust (Edition 2024), standard SQLite 3 storage, standard TOML configuration, and POSIX-compliant systemd integration. |
|
||||
| 🌐 **Open Source First** | 100% free and open-source software under permissive licensing. | Dual-licensed under `MIT OR Apache-2.0`. Complete freedom to inspect, audit, build, and extend. |
|
||||
| ♾️ **Zero Vendor Lock-In** | Standard data formats, open protocols, and zero proprietary cloud lock-in. | Standard SQLite database, standard WireGuard `.conf` files, standard RFC-compliant JSON/YAML/CSV output formats, and open Netlink sockets. |
|
||||
|
||||
---
|
||||
|
||||
## 3. The Architectural Paradigm Shift
|
||||
|
||||
### Typical WireGuard Management Wrappers
|
||||
```
|
||||
┌──────────┐ ┌──────────────────────┐ ┌────────────────────────────┐ ┌──────────────┐
|
||||
│ Web UI │ ──► │ Text Config Files │ ──► │ wg / ip / nft / sysctl │ ──► │ Linux Kernel │
|
||||
│ (Node/Py)│ │(/etc/wireguard/*.conf│ │ (Subprocess Spawning) │ │ (wireguard.ko│
|
||||
└──────────┘ └──────────────────────┘ └────────────────────────────┘ └──────────────┘
|
||||
```
|
||||
*Disadvantages: Fragile process spawning, race conditions, lack of atomic state, broken host routing, vulnerability to configuration drift, and heavy runtime dependency footprints.*
|
||||
|
||||
### Whereas `nx9-wg` is a Native Control & Execution Plane:
|
||||
```
|
||||
┌───────────────────────────────────┐
|
||||
│ nx9-wg │
|
||||
└─────────────────┬─────────────────┘
|
||||
│
|
||||
┌────────────────────────────────┴────────────────────────────────┐
|
||||
│ │
|
||||
┌─────────▼─────────┐ ┌─────────▼─────────┐
|
||||
│ Desired State │ │ Live State │
|
||||
│ (Authoritative) │ │ (Kernel Cache) │
|
||||
└─────────┬─────────┘ └─────────▲─────────┘
|
||||
│ │
|
||||
┌─────────▼─────────┐ ┌─────────┴─────────┐
|
||||
│ SQLite 3 (WAL) │ │ Linux Kernel │
|
||||
└─────────┬─────────┘ └─────────▲─────────┘
|
||||
│ │
|
||||
│ Live Netlink Telemetry
|
||||
▼ │
|
||||
┌───────────────────┐ │
|
||||
│ Reconciliation │ ◄─────────────────────────────────────────────────────┘
|
||||
│ Engine │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
├── 🔐 WireGuard Generic Netlink (family "wireguard")
|
||||
├── 🌐 RTNETLINK (Links, IPv4/IPv6 Addresses, Routes)
|
||||
├── 🔥 Netfilter / libnftables FFI (table inet nx9_wg)
|
||||
└── ↔️ Direct Procfs IP Forwarding (/proc/sys/net)
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Linux Networking │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Six-Crate Workspace Architecture
|
||||
|
||||
`nx9-wg` is architected as a modular six-crate Cargo workspace ensuring clean separation of concerns, zero circular dependencies, and isolated testability:
|
||||
|
||||
```
|
||||
nx9-wg (Root Executable & Unified Entrypoint)
|
||||
├── 📦 crates/nx9-wg-core — Domain models, RFC validation, cryptography, configuration
|
||||
├── 📦 crates/nx9-wg-db — SQLite storage engine, migration framework, repositories
|
||||
├── 📦 crates/nx9-wireguard — WireGuard Generic Netlink, RTNETLINK link engine, config & QR
|
||||
├── 📦 crates/nx9-wg-network — RTNETLINK routes/addresses, libnftables Netfilter, procfs
|
||||
├── 📦 crates/nx9-wg-api — Axum REST router, WebSockets, auth, reconciliation, IP allocator
|
||||
└── 📦 crates/nx9-wg-ui — Design tokens, CSS compiler, view models, SPA integration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Complete Capability Inventory
|
||||
|
||||
`nx9-wg` delivers a comprehensive suite of 20 core infrastructure capabilities:
|
||||
|
||||
| Icon | Capability | Architectural Description |
|
||||
| :---: | :--- | :--- |
|
||||
| 🔐 | **WireGuard Interface Lifecycle** | In-process RTNETLINK link creation (`RTM_NEWLINK`), state toggling (`IFF_UP`/`IFF_DOWN`), and WireGuard Generic Netlink cryptokey configuration (`WG_CMD_SET_DEVICE`). |
|
||||
| 👥 | **Cryptographic Peer Enrollment** | Dynamic Curve25519 public key management, preshared keys, CIDR allowed IPs, persistent keepalive intervals, and atomic `ReplacePeers` synchronization. |
|
||||
| 🌐 | **IPv4 & IPv6 Address Management** | Native Netlink address assignment (`RTM_NEWADDR` / `RTM_DELADDR`) across WireGuard interfaces without invoking `ip addr`. |
|
||||
| 🛣️ | **Kernel Route Management** | Routing table synchronization (`RTM_NEWROUTE` / `RTM_DELROUTE`) with strict default gateway protection and multi-tenant non-interference invariants. |
|
||||
| 🔥 | **nftables Netfilter Firewall** | In-process rule compilation and transactional application via `libnftables.so.1` confined strictly to `table inet nx9_wg`. |
|
||||
| 🛡️ | **Scoped NAT & Masquerading** | Outbound NAT masquerade dynamically calculated and applied strictly to managed WireGuard client subnets, preventing host network disruption. |
|
||||
| ↔️ | **Direct Procfs IP Forwarding** | Direct atomic mutation of `/proc/sys/net/ipv4/ip_forward` and `/proc/sys/net/ipv6/conf/all/forwarding` without invoking `sysctl`. |
|
||||
| 📡 | **Live Kernel Telemetry** | Real-time extraction of handshake timestamps, rx/tx byte counters, and roaming remote socket endpoints directly from kernel sockets. |
|
||||
| 🔄 | **Desired-State Reconciliation** | Continuous closed-loop control cycle bringing the Linux kernel execution plane into alignment with authoritative SQLite storage. |
|
||||
| 🧭 | **5-Subsystem Drift Detection** | Deterministic, read-only calculation of state divergence across interfaces, peers, routes, firewall rules, and IP forwarding. |
|
||||
| ♻️ | **Cold-Boot Restart Recovery** | Autonomous reconstruction of kernel network topology, routes, and firewall rules upon daemon startup or host reboot. |
|
||||
| 🧪 | **Cross-Platform Simulation Engine** | In-memory simulated execution planes enabling full UI and CLI development on macOS and Windows without requiring Linux Netlink. |
|
||||
| 🖥️ | **Multi-Format Native CLI** | 100% native CLI coverage across all 17 subcommands supporting `table`, `json`, `yaml`, and `csv` output with script-friendly exit codes. |
|
||||
| 🌐 | **Axum REST API & WebSockets** | High-performance asynchronous HTTP server with session/bearer authentication and a real-time WebSocket event broadcaster (`/api/v1/ws`). |
|
||||
| 💻 | **Embedded Zero-Dependency SPA** | Modern HTML5/CSS3/Vanilla ES6+ Single Page Application compiled into the binary with Light/Dark theme support and 15 interactive views. |
|
||||
| 📊 | **Automated Health Diagnostics** | Built-in inspection across 9 subsystems with structured status reporting, root-cause diagnostics, and actionable remediation hints. |
|
||||
| 💾 | **Atomic Disaster Recovery Backups** | Online non-blocking SQLite `VACUUM INTO` snapshots with SHA-256 manifest verification and automatic pre-restore safety checkpoints. |
|
||||
| 🔑 | **Single-Administrator Security** | Database-enforced single identity (`CHECK (id=1)`), Argon2id password hashing, SHA-256 API token storage, and brute-force login rate limiting. |
|
||||
| 📱 | **Client Profiles & QR Engine** | Provider/device MTU profiles (1280 vs 1360 vs 1420), collision-resistant IP allocation, and pure Rust vector SVG, PNG, and ASCII QR generation. |
|
||||
| 📦 | **Production Release Packaging** | Standalone distribution archive generator (`scripts/package-release.sh`), automated installer/uninstaller, and hardened systemd service unit. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Native Linux Execution Planes
|
||||
|
||||
`nx9-wg` enforces a **Zero Subprocess Guarantee** across the entire production codebase:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Rust Production Binary │
|
||||
├────────────────────────────┬────────────────────────────┬──────────────────────────────┤
|
||||
│ NativeLinuxWireGuardEngine │ NativeLinuxNetworkEngine │ NativeLinuxNftablesEngine │
|
||||
├────────────────────────────┼────────────────────────────┼──────────────────────────────┤
|
||||
│ • AF_NETLINK │ • NETLINK_ROUTE │ • In-process libnftables FFI │
|
||||
│ • NETLINK_GENERIC (wg) │ • RTM_NEWLINK / DELLINK │ • nft_ctx_new() │
|
||||
│ • WG_CMD_SET_DEVICE │ • RTM_NEWADDR / DELADDR │ • Atomic Netfilter batch │
|
||||
│ • WG_CMD_GET_DEVICE │ • RTM_NEWROUTE / DELROUTE │ • Scoped: table inet nx9_wg │
|
||||
│ • WGDEVICE_F_REPLACE_PEERS │ • Direct /proc/sys writes │ • Zero host table flushes │
|
||||
└─────────────┬──────────────┴─────────────┬──────────────┴──────────────┬───────────────┘
|
||||
▼ ▼ ▼
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Linux Kernel │
|
||||
│ (wireguard.ko • RTNETLINK • Netfilter • /proc/sys/net) │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Closed-Loop Reconciliation & Convergence
|
||||
|
||||
Reconciliation is the foundational control loop that bridges authoritative SQLite state with the Linux kernel:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 1. SQLite Desired State (Authoritative Persistent Source of Truth│
|
||||
└────────────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 2. Live Kernel Query (WireGuard Genl, RTNL routes, table nx9_wg) │
|
||||
└────────────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Drift Detection (Read-Only Deterministic Multi-Subsystem Diff)│
|
||||
└────────────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
│ has_drift == false? │
|
||||
├───────────────────────┬───────┤
|
||||
│ YES │ NO │
|
||||
▼ ▼ │
|
||||
┌─────────────┐ ┌──────────────▼────────────────┐
|
||||
│ Converged │ │ 4. Acquire Async Mutex Lock │
|
||||
│ (In Sync) │ └──────────────┬────────────────┘
|
||||
└─────────────┘ │
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ 5. Execute Native Mutations │
|
||||
│ (Genl SET_DEVICE, RTNL) │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ 6. Post-Apply Verification │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Converged │ │ Partial │
|
||||
│ (100% Sync)│ │ Failure │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### The Six Reconciliation Lifecycle States
|
||||
1. **`Plan`**: Read-only calculation of drift between SQLite and kernel.
|
||||
2. **`Applying`**: In-progress dispatch of native mutations across execution planes.
|
||||
3. **`Verifying`**: Querying live kernel state to confirm applied changes took effect.
|
||||
4. **`Converged`**: 100% synchronization achieved with zero remaining drift.
|
||||
5. **`PartialFailure`**: One or more execution planes failed during apply (e.g. permission error).
|
||||
6. **`DriftRemains`**: Apply completed without fatal error, but post-verification detected unapplied state.
|
||||
|
||||
---
|
||||
|
||||
## 8. Embedded Single Page Application (SPA) & WebSockets
|
||||
|
||||
The `nx9-wg` frontend is a zero-dependency HTML5/CSS/JavaScript SPA embedded directly into the Rust binary:
|
||||
|
||||
- **Zero External Toolchains**: No Node.js, npm, Webpack, Vite, React, or external CDN dependencies.
|
||||
- **Embedded In-Memory Delivery**: Bundled at compile-time via `include_str!()` and served from memory.
|
||||
- **Design Tokens**: Custom CSS token system ([`crates/nx9-wg-ui/src/css.rs`](file:///home/sunil/Programs/nx9-wg/crates/nx9-wg-ui/src/css.rs)) supporting Light and Dark modes.
|
||||
- **Real-Time WebSocket Stream**: Subscribes to `ws://<host>/api/v1/ws` for live handshakes and drift alerts without polling.
|
||||
- **15 Interactive Views**:
|
||||
1. `#dashboard` — System overview, uptime, interface/peer counts, health cards.
|
||||
2. `#interfaces` — WireGuard interface CRUD, listen port, MTU, state toggles.
|
||||
3. `#peers` — Enrolled peer table, real-time handshakes, profile resolution, `.conf` export, SVG QR modal.
|
||||
4. `#networks` — Subnet network ranges, CIDR masks, available IP inspector.
|
||||
5. `#routes` — Kernel route definitions, gateway assignments, interface scoping.
|
||||
6. `#firewall` — nftables packet filtering rules in `table inet nx9_wg`, priority sorting.
|
||||
7. `#nat` — Managed subnet NAT masquerade status and instant toggle.
|
||||
8. `#forwarding` — Kernel IPv4/IPv6 packet forwarding status and toggle.
|
||||
9. `#reconciliation` — Real-time kernel drift overview, action plan table, interactive Apply button.
|
||||
10. `#diagnostics` — Automated multi-subsystem health checks with remediation hints.
|
||||
11. `#live-state` — Raw Linux Netlink telemetry, active kernel interfaces, live routing table.
|
||||
12. `#settings` — Key-value appliance parameters and danger zone reset controls.
|
||||
13. `#backups` — Online SQLite backup snapshots list, instant backup creation, `.db` download.
|
||||
14. `#audit` — Append-only security and administrative audit trail.
|
||||
15. `#administrator` — Admin account verification, password rotation, and one-time API token generation.
|
||||
|
||||
---
|
||||
|
||||
## 9. REST API & WebSocket Protocol Reference
|
||||
|
||||
### Authentication Mechanisms
|
||||
- **Session Cookie**: `nx9_session=<UUID>` returned via `POST /api/v1/auth/login`.
|
||||
- **Bearer Token**: `Authorization: Bearer nx9_<UUID>_<SECRET>` passed in HTTP headers.
|
||||
|
||||
### Complete REST Route Inventory
|
||||
```http
|
||||
POST /api/v1/auth/login - Authenticate administrator & create session
|
||||
POST /api/v1/auth/logout - Invalidate active session
|
||||
GET /api/v1/auth/session - Query authenticated session info
|
||||
POST /api/v1/auth/password - Rotate admin password (invalidates all sessions)
|
||||
GET /api/v1/auth/tokens - List active API token metadata
|
||||
POST /api/v1/auth/tokens - Generate new API token (one-time raw secret return)
|
||||
DELETE /api/v1/auth/tokens/{id} - Revoke an API token
|
||||
|
||||
GET /api/v1/system - System operational overview and object counts
|
||||
GET /api/v1/system/health - Public health check endpoint
|
||||
GET /api/v1/system/version - Version, build edition, and architecture
|
||||
GET /api/v1/system/settings - List all appliance key-value settings
|
||||
PUT /api/v1/system/settings - Upsert appliance setting
|
||||
|
||||
GET /api/v1/interfaces - List all WireGuard interfaces
|
||||
POST /api/v1/interfaces - Create WireGuard interface
|
||||
GET /api/v1/interfaces/{id} - Get interface details
|
||||
PUT /api/v1/interfaces/{id} - Update interface configuration
|
||||
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}/disable - Set interface IFF_DOWN
|
||||
GET /api/v1/interfaces/{id}/status - Query live kernel netlink telemetry
|
||||
GET /api/v1/interfaces/{id}/peers - List peers attached to interface
|
||||
POST /api/v1/interfaces/{id}/peers - Enroll new peer on interface
|
||||
|
||||
GET /api/v1/peers/{id} - Get peer details
|
||||
PUT /api/v1/peers/{id} - Update peer parameters
|
||||
DELETE /api/v1/peers/{id} - Delete peer
|
||||
POST /api/v1/peers/{id}/enable - Enable peer
|
||||
POST /api/v1/peers/{id}/disable - Disable peer
|
||||
GET /api/v1/peers/{id}/config - Download client .conf file
|
||||
GET /api/v1/peers/{id}/qr - Render QR code (SVG / PNG / ASCII)
|
||||
|
||||
GET /api/v1/networks - List subnet networks
|
||||
POST /api/v1/networks - Create subnet network
|
||||
GET /api/v1/networks/{id} - Get network details
|
||||
DELETE /api/v1/networks/{id} - Delete network
|
||||
GET /api/v1/networks/{id}/available - List available unallocated IP addresses
|
||||
|
||||
GET /api/v1/routes - List kernel routing entries
|
||||
POST /api/v1/routes - Create routing entry
|
||||
DELETE /api/v1/routes/{id} - Delete routing entry
|
||||
|
||||
GET /api/v1/firewall/rules - List nftables firewall rules
|
||||
POST /api/v1/firewall/rules - Create firewall rule
|
||||
DELETE /api/v1/firewall/rules/{id} - Delete firewall rule
|
||||
POST /api/v1/firewall/rules/{id}/enable - Enable firewall rule
|
||||
POST /api/v1/firewall/rules/{id}/disable - Disable firewall rule
|
||||
|
||||
GET /api/v1/reconcile/plan - Read-only drift calculation plan
|
||||
POST /api/v1/reconcile/apply - Serialized kernel apply and convergence check
|
||||
|
||||
GET /api/v1/diagnostics/all - Run full diagnostics across all 9 subsystems
|
||||
GET /api/v1/diagnostics/{subsystem} - Run diagnostics for single subsystem
|
||||
|
||||
GET /api/v1/backups - List backup records
|
||||
POST /api/v1/backups/create - Trigger atomic online VACUUM INTO snapshot
|
||||
GET /api/v1/backups/{id}/download - Download raw SQLite database snapshot
|
||||
POST /api/v1/backups/{id}/restore - Restore database with automatic safety backup
|
||||
DELETE /api/v1/backups/{id} - Delete backup snapshot file and metadata
|
||||
|
||||
GET /api/v1/audit - Query append-only audit trail
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Native CLI Command System
|
||||
|
||||
`nx9-wg` provides 100% native CLI coverage across all 17 subcommands:
|
||||
|
||||
```bash
|
||||
# Global output formats: --format table | json | yaml | csv
|
||||
nx9-wg version
|
||||
nx9-wg serve --bind 127.0.0.1:8080
|
||||
nx9-wg init --generate-password --write-password-file /var/lib/nx9-wg/admin-password
|
||||
|
||||
# Administration & Authentication
|
||||
nx9-wg admin info
|
||||
nx9-wg admin password
|
||||
nx9-wg admin token create "ci-pipeline" --expires-in-days 90 --write-token-file /tmp/token
|
||||
nx9-wg admin token list
|
||||
nx9-wg admin token revoke <TOKEN_UUID>
|
||||
|
||||
# Interface & Peer Management
|
||||
nx9-wg interface list
|
||||
nx9-wg interface create wg0 --address-v4 10.100.0.1/24 --port 51820 --mtu 1420
|
||||
nx9-wg peer create --interface wg0 --name alice --profile full_tunnel --mtu 1280
|
||||
nx9-wg peer qr <PEER_UUID>
|
||||
nx9-wg peer config <PEER_UUID>
|
||||
|
||||
# Networking, Firewall & NAT
|
||||
nx9-wg route add --destination 192.168.50.0/24 --gateway 10.100.0.2 --interface-name wg0
|
||||
nx9-wg firewall add --name "allow-dns" --protocol udp --port 53 --action accept --priority 10
|
||||
nx9-wg nat enable
|
||||
nx9-wg forwarding enable
|
||||
|
||||
# State Reconciliation & Diagnostics
|
||||
nx9-wg reconcile plan
|
||||
nx9-wg reconcile apply
|
||||
nx9-wg diagnostics all
|
||||
|
||||
# Disaster Recovery
|
||||
nx9-wg backup create --description "Pre-upgrade snapshot"
|
||||
nx9-wg backup verify /var/lib/nx9-wg/backups/nx9-backup-...db
|
||||
nx9-wg backup restore <BACKUP_UUID>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Security Model & Capability Isolation
|
||||
|
||||
### A. Single Administrator Identity
|
||||
- Database-level integrity constraint: `CHECK (id = 1)` in `admins` table.
|
||||
- Eliminates multi-tenant privilege escalation and role-confusion attack surfaces.
|
||||
|
||||
### B. Credential Protection
|
||||
- **Passwords**: Hashed with Argon2id using unique cryptographic salts.
|
||||
- **API Tokens**: Stored exclusively as SHA-256 digests in SQLite; raw tokens are displayed once upon creation.
|
||||
- **Secret Redaction**: Private keys, preshared keys, and password hashes implement custom `std::fmt::Debug` implementations returning `[REDACTED]`.
|
||||
|
||||
### C. Hardened systemd Sandbox (`nx9-wg.service`)
|
||||
```ini
|
||||
[Unit]
|
||||
Description=NX9 WireGuard Native VPN Platform
|
||||
After=network.target network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/nx9-wg serve
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
|
||||
# Minimal Linux Capabilities
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
|
||||
# Sandboxing Directives
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
# Allowed Network Address Families
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
|
||||
# Explicit Read-Write Paths (for state and direct procfs forwarding)
|
||||
ReadWritePaths=/var/lib/nx9-wg /etc/nx9-wg /var/log/nx9-wg /proc/sys/net
|
||||
ProtectKernelTunables=false
|
||||
|
||||
# Systemd Directory Management
|
||||
StateDirectory=nx9-wg
|
||||
ConfigurationDirectory=nx9-wg
|
||||
LogsDirectory=nx9-wg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Disaster Recovery, Backups & Upgrades
|
||||
|
||||
### Atomic Online Backups
|
||||
- Uses SQLite `VACUUM INTO` to produce non-blocking, consistent binary database snapshots while the daemon is actively serving traffic.
|
||||
- Generates SHA-256 manifest records for each snapshot.
|
||||
|
||||
### Safe Rollback Workflow
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ 1. Operator Initiates Restore (nx9-wg backup restore) │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼────────────────────────────┐
|
||||
│ 2. Verify Backup File Size, Magic Header & SHA-256 │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼────────────────────────────┐
|
||||
│ 3. Create Pre-Restore Safety Snapshot (Automatic Fallback│
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼────────────────────────────┐
|
||||
│ 4. Close Connection Pools, Replace .db, Clean WAL/SHM │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼────────────────────────────┐
|
||||
│ 5. Reopen Database & Execute Reconciliation Engine │
|
||||
│ (Kernel state converged to restored desired state) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Release Engineering & Deployment Lifecycle
|
||||
|
||||
### Automated Packaging Pipeline ([`scripts/package-release.sh`](file:///home/sunil/Programs/nx9-wg/scripts/package-release.sh))
|
||||
Generates self-contained, reproducible distribution archives in `target/dist/`:
|
||||
- `nx9-wg-v1.0.0-linux-x86_64.tar.gz` (7.8 MB)
|
||||
- `nx9-wg-v1.0.0-linux-x86_64.tar.xz` (5.0 MB)
|
||||
- `nx9-wg-v1.0.0-linux-x86_64.sha256` (Cryptographic checksum manifest)
|
||||
|
||||
### Production Filesystem Layout & Permissions
|
||||
```
|
||||
/usr/local/bin/nx9-wg 0755 root:root - Native Executable Binary
|
||||
/etc/nx9-wg/ 0750 root:root - Configuration Directory
|
||||
└── config.toml 0640 root:root - Production Configuration
|
||||
/var/lib/nx9-wg/ 0700 root:root - State & SQLite Directory
|
||||
├── nx9-wg.db 0600 root:root - Authoritative SQLite Database
|
||||
├── nx9-wg.db-wal 0600 root:root - WAL Journal
|
||||
├── admin-password 0600 root:root - Initial Bootstrap Password
|
||||
└── backups/ 0700 root:root - Backup Snapshots Directory
|
||||
/var/log/nx9-wg/ 0750 root:root - Operational Logs
|
||||
/etc/systemd/system/nx9-wg.service 0644 root:root - Hardened Service Unit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Quality Assurance & Verification Evidence
|
||||
|
||||
All quality gates have been executed and verified clean:
|
||||
|
||||
| Quality Gate | Verification Command | Result |
|
||||
| :--- | :--- | :---: |
|
||||
| **Code Formatting** | `cargo fmt --all -- --check` | **PASS** (Zero diffs) |
|
||||
| **Workspace Compilation** | `cargo check --workspace` | **PASS** (Zero errors) |
|
||||
| **Workspace Unit Tests** | `cargo test --workspace` | **PASS** (**162 / 162 passed**, 100%) |
|
||||
| **Clippy Linter Check** | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | **PASS** (Zero warnings) |
|
||||
| **Comprehensive CLI Suite** | `LIVE=0 bash scripts/test-cli-comprehensive.sh` | **PASS** (**203 passed** / 7 skipped) |
|
||||
| **Native Integration Suite** | `LIVE=0 bash scripts/test-native-integration.sh` | **PASS** (**19 passed** / 1 skipped) |
|
||||
| **Dedicated Live Kernel Suite** | `LIVE=0 bash scripts/test-live-kernel.sh` | **PASS** (**23 passed** / 1 skipped) |
|
||||
| **Subprocess Safety Audit** | Automated source scan for `Command::new` | **PASS** (Zero subprocesses) |
|
||||
| **Secret Leakage Audit** | Automated audit for plaintext credentials | **PASS** (Zero secrets leaked) |
|
||||
| **Standalone Package Verification**| Fresh directory extraction & independent run | **PASS** (Standalone execution) |
|
||||
| **Git Diff Whitespace Audit** | `git diff --check` | **PASS** (Zero whitespace issues) |
|
||||
|
||||
---
|
||||
|
||||
## 15. Ecosystem & License Summary
|
||||
|
||||
`nx9-wg` is part of the **NX9 Ecosystem** ([https://nx9.in](https://nx9.in)) created by **Sunil Thakare**.
|
||||
|
||||
Dual-licensed under either:
|
||||
- **MIT License** ([`LICENSE-MIT`](file:///home/sunil/Programs/nx9-wg/LICENSE-MIT))
|
||||
- **Apache License, Version 2.0** ([`LICENSE-APACHE`](file:///home/sunil/Programs/nx9-wg/LICENSE-APACHE))
|
||||
|
||||
at your option.
|
||||
|
||||
---
|
||||
|
||||
> **NX9 WireGuard** — Sovereign, Self-Hosted, Linux-Native Network Infrastructure.
|
||||
@@ -4,237 +4,467 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> **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
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
Desired State Live State
|
||||
│ │
|
||||
SQLite Linux Kernel
|
||||
│ ▲
|
||||
▼ │
|
||||
Reconciliation ◄──────── Telemetry
|
||||
│
|
||||
├── WireGuard Generic Netlink
|
||||
├── RTNETLINK
|
||||
├── Netfilter / libnftables
|
||||
└── procfs
|
||||
│
|
||||
▼
|
||||
Linux networking
|
||||
┌───────────────────────────────────┐
|
||||
│ nx9-wg │
|
||||
└─────────────────┬─────────────────┘
|
||||
│
|
||||
┌────────────────────────────────┴────────────────────────────────┐
|
||||
│ │
|
||||
┌─────────▼─────────┐ ┌─────────▼─────────┐
|
||||
│ Desired State │ │ Live State │
|
||||
│ (Authoritative) │ │ (Kernel Cache) │
|
||||
└─────────┬─────────┘ └─────────▲─────────┘
|
||||
│ │
|
||||
┌─────────▼─────────┐ ┌─────────┴─────────┐
|
||||
│ SQLite 3 (WAL) │ │ Linux Kernel │
|
||||
└─────────┬─────────┘ └─────────▲─────────┘
|
||||
│ │
|
||||
│ Live Netlink Telemetry
|
||||
▼ │
|
||||
┌───────────────────┐ │
|
||||
│ Reconciliation │ ◄─────────────────────────────────────────────────────┘
|
||||
│ Engine │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
├── 🔐 WireGuard Generic Netlink (family "wireguard")
|
||||
├── 🌐 RTNETLINK (Links, IPv4/IPv6 Addresses, Routes)
|
||||
├── 🔥 Netfilter / libnftables FFI (table inet nx9_wg)
|
||||
└── ↔️ Direct Procfs IP Forwarding (/proc/sys/net)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Capabilities Beyond Interface Creation
|
||||
|
||||
- 🔐 **WireGuard interface and peer lifecycle** (RTNETLINK + WireGuard Generic Netlink)
|
||||
- 🌐 **IPv4/IPv6 address management** (In-process `RTM_NEWADDR` / `RTM_DELADDR`)
|
||||
- 🛣️ **Route management & protected route reconciliation** (Zero default-route interference)
|
||||
- 🔥 **Firewall rule management** (In-process `libnftables.so.1` FFI in `table inet nx9_wg`)
|
||||
- 🛡️ **Scoped NAT/masquerading** (Strictly scoped to managed WireGuard client subnets)
|
||||
- ↔️ **IPv4/IPv6 forwarding** (Direct atomic `/proc/sys/net` sysctl control)
|
||||
- 📡 **Live WireGuard telemetry** (Handshake timestamps, authenticated roaming endpoints, byte counters)
|
||||
- 🔄 **Desired-state reconciliation** (Continuous closed-loop convergence)
|
||||
- 🧭 **Drift detection** (5-subsystem read-only deterministic planning)
|
||||
- ♻️ **Restart recovery** (Cold-boot reconstruction of live kernel networking from SQLite)
|
||||
- 🧪 **Simulation engine** (Full macOS/Windows local development fallback)
|
||||
- 🖥️ **CLI + REST API + WebSocket + SPA** (Zero-dependency embedded interface)
|
||||
- 📊 **Diagnostics** (Automated health inspection across 9 subsystems with remediation hints)
|
||||
- 💾 **Backup/restore** (Atomic online `VACUUM INTO` snapshots with SHA-256 manifests)
|
||||
- 🔑 **Administrator/authentication/API tokens** (Argon2id, SHA-256 tokens, single-admin `CHECK (id=1)`)
|
||||
- 📱 **Client profiles + generated configurations + QR** (Pure Rust SVG, PNG, and ASCII QR engine)
|
||||
- 📦 **Standalone Linux deployment** (Zero scripting runtime, self-contained distribution packages)
|
||||
- 🔒 **systemd capability isolation** (`CAP_NET_ADMIN`, `CAP_NET_BIND_SERVICE`, full sandbox directives)
|
||||
- **Explicit Interface Roles (Overlay vs Upstream)**: Formal separation of the primary protected overlay interface (`wg0`) from optional third-party WireGuard VPN upstream interfaces (e.g. `proton0`).
|
||||
- **Third-Party WireGuard .conf Import**: In-process parser and validator for standard `.conf` files (supporting single `[Interface]` and single `[Peer]`), with live configuration preview before atomic database persistence.
|
||||
- **Optional Local Listen Ports**: Strict modeling of `Interface.listen_port` as `Option<u16>`, allowing Linux WireGuard to select ephemeral dynamic UDP ports when `ListenPort` is omitted from imported configurations, preventing local port collisions with `wg0` (51820).
|
||||
- **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 role-aware cryptokey routing.
|
||||
- **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.
|
||||
- **Strict AllowedIPs Semantic Separation**: Correctly derives server-side cryptokey routing AllowedIPs (`/32` and `/128`) from assigned tunnel addresses for Overlay peers, while preserving full-tunnel provider AllowedIPs (`0.0.0.0/0, ::/0`) for Upstream peers without mutating the host default routing table.
|
||||
- **IPv4/IPv6 Address Management**: In-process `RTM_NEWADDR` and `RTM_DELADDR` Netlink execution without invoking `ip addr`.
|
||||
- **Protected Route Management**: In-process routing table reconciliation protecting host default routes from accidental disruption.
|
||||
- **In-Process nftables Firewall & NAT**: Transactional rule compilation via `libnftables.so.1` strictly scoped to `table inet nx9_wg`.
|
||||
- **Scoped Outbound NAT Masquerade**: Automated masquerading scoped to managed WireGuard client subnets and non-WireGuard egress interfaces.
|
||||
- **Atomic IP Packet Forwarding**: Direct `/proc/sys/net/ipv4/ip_forward` and IPv6 forwarding control.
|
||||
- **Live Kernel Telemetry**: Live handshake timestamps, authenticated roaming endpoints, and 64-bit RX/TX byte counters merged into API and WebUI responses.
|
||||
- **Closed-Loop Reconciliation**: Continuous drift detection, dry-run deterministic planning, orphan interface removal, and serialized convergence with empty-desired-state safety guards.
|
||||
- **Cold-Boot Restart Recovery**: Deterministic reconstruction of live kernel networking from authoritative SQLite state upon boot.
|
||||
- **Single Administrator Identity**: Database-level `CHECK (id = 1)` constraint, Argon2id password hashing, and SHA-256 API token digests.
|
||||
- **Zero-Dependency Single Page Application (SPA)**: Embedded HTML5/CSS/JS frontend with dark/light themes, live WebSocket telemetry, responsive mobile-first UI, Upstream import modal with live preview, and read-only CLI console.
|
||||
- **Pure Rust Client Configuration & QR**: In-process generation of standard `.conf` text and SVG, PNG, and terminal ASCII QR codes.
|
||||
- **Automated Health Diagnostics**: Deep inspection across 11 subsystems with actionable remediation hints.
|
||||
- **Atomic SQLite Online Backups**: Non-blocking `VACUUM INTO` snapshots with SHA-256 integrity manifests and pre-restore safety snapshots.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## SQLite as the Single Authority
|
||||
## 3. Core Design Principles
|
||||
|
||||
The foundational architectural choice in `nx9-wg` is **SQLite as the authoritative desired state**.
|
||||
|
||||
WireGuard is not the configuration database. The kernel is not the configuration database either.
|
||||
|
||||
```
|
||||
SQLite
|
||||
│
|
||||
│ 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.
|
||||
| Principle | Technical Implementation |
|
||||
| :--- | :--- |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
- ❌ `wg-quick`
|
||||
- ❌ `ip`
|
||||
- ❌ `iptables`
|
||||
- ❌ `nft` CLI
|
||||
- ❌ `sysctl` CLI
|
||||
- ❌ shell 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`.
|
||||
- [**`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.
|
||||
- [**`crates/nx9-wg-db`**](crates/nx9-wg-db): Authoritative SQLite store, 13 relational tables, automated migrations via `sqlx`, and repository implementations in WAL mode.
|
||||
- [**`crates/nx9-wireguard`**](crates/nx9-wireguard): WireGuard Generic Netlink execution, RTNETLINK link management, client `.conf` generator, and pure Rust QR engine (SVG, PNG, ASCII).
|
||||
- [**`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.
|
||||
- [**`crates/nx9-wg-api`**](crates/nx9-wg-api): Axum REST router, WebSocket real-time broadcaster, session/token authentication, deterministic IP allocator, and reconciliation engine.
|
||||
- [**`crates/nx9-wg-ui`**](crates/nx9-wg-ui): Pure CSS design system, responsive stylesheet compiler, view models, and embedded SPA assets.
|
||||
- **`src/main.rs`**: Root executable CLI providing full subcommand coverage and daemon orchestration.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
## 5. Persistent WireGuard Server Endpoint Configuration
|
||||
|
||||
| Subsystem | Status | Verification Evidence |
|
||||
| :--- | :---: | :--- |
|
||||
| **System Architecture** | **Complete** | Six-crate modular workspace with strict layer boundaries |
|
||||
| **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 |
|
||||
| **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` |
|
||||
| **Reconciliation Engine** | **Implemented** | Closed-loop drift detection, read-only plan, and serialized apply |
|
||||
| **Web User Interface** | **Verified** | Zero-dependency SPA with theme engine and 15 interactive routes |
|
||||
| **Release & Deployment** | **Implemented** | Standalone installer, uninstaller, packaging script, and systemd unit |
|
||||
| **SAFE Verification Suite** | **PASS** | 162 workspace tests, 203 CLI tests, 19 integration tests, 23 live tests |
|
||||
| **LIVE Kernel Verification** | **Framework Ready** | SAFE mode (`LIVE=0`) verified; dedicated host ready via `LIVE=1` |
|
||||
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.
|
||||
|
||||
### Setting Keys in SQLite
|
||||
|
||||
| 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 (`1..=65535`). | `51820` | `51820` |
|
||||
| `wireguard.server_endpoint_enabled` | Boolean | Whether the persistent 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` |
|
||||
|
||||
### 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
|
||||
```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:
|
||||
### Quick Start (Pre-Built Archive)
|
||||
|
||||
```bash
|
||||
# Download and extract release archive:
|
||||
tar -xzf nx9-wg-v1.0.0-linux-x86_64.tar.gz
|
||||
cd nx9-wg-v1.0.0-linux-x86_64
|
||||
# Extract release archive:
|
||||
tar -xzf nx9-wg-v1.1.0-linux-x86_64.tar.gz
|
||||
cd nx9-wg-v1.1.0-linux-x86_64
|
||||
|
||||
# Run production installer:
|
||||
# Run production installer as root:
|
||||
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 with explicit **Role** badges (`Overlay` vs `Upstream`), "+ Create Interface" modal with tabbed **Standard Overlay** vs **Import Upstream VPN** (`.conf` parser & live preview), interface **Edit** action (preserves private/public key identity), **Restart** action (link teardown + re-sync), enable/disable toggle, and delete action (protected against `wg0`), plus an embedded read-only CLI console.
|
||||
- **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 & Restart
|
||||
nx9-wg interface create wg0 --address-v4 10.100.0.1/24 --port 51820 --mtu 1420
|
||||
nx9-wg interface update wg0 --mtu 1420
|
||||
nx9-wg interface restart wg0
|
||||
|
||||
# 3. Third-Party Upstream Management (e.g. ProtonVPN)
|
||||
nx9-wg interface upstream import proton0 --file /path/to/protonvpn.conf
|
||||
nx9-wg interface upstream list
|
||||
nx9-wg interface upstream show proton0
|
||||
nx9-wg interface upstream status proton0
|
||||
nx9-wg interface upstream restart proton0
|
||||
|
||||
# 4. 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
|
||||
|
||||
# 5. Reconciliation
|
||||
nx9-wg reconcile plan
|
||||
nx9-wg reconcile apply
|
||||
nx9-wg reconcile verify
|
||||
|
||||
# 6. Live Telemetry & Diagnostics
|
||||
nx9-wg live peer wg0
|
||||
nx9-wg diagnostics all
|
||||
|
||||
# 7. 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 195 tests passing) |
|
||||
| **CLI Test Suite** | `cargo test --test test_cli_commands` | **PASS** (12 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) |
|
||||
| **System Architecture** | [**Architecture Reference**](docs/architecture.md) |
|
||||
| **Installation & Setup** | [**Installation Guide**](docs/installation.md) |
|
||||
| **Platform Requirements** | [**Linux Requirements**](docs/linux_requirements.md) |
|
||||
| **Native WireGuard** | [**Native WireGuard Engine**](docs/native-wireguard.md) |
|
||||
| **Native Networking** | [**Native Network & Routing Engine**](docs/native-network.md) |
|
||||
| **nftables & NAT** | [**Native nftables Engine**](docs/nftables.md) • [**Firewall/NAT Model**](docs/firewall_nat.md) |
|
||||
| **State Reconciliation** | [**Reconciliation & Convergence**](docs/reconciliation.md) |
|
||||
| **Web User Interface** | [**Web UI & SPA Routes**](docs/ui.md) |
|
||||
| **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) |
|
||||
| [`scripts/verify.sh`](scripts/verify.sh) | **Development Verification**: Executes formatting check, workspace check, Clippy with `-D warnings`, full test suite, and comprehensive CLI verification. |
|
||||
| [`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`. |
|
||||
| [`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. |
|
||||
| [`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. |
|
||||
| [`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. |
|
||||
| [`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. |
|
||||
| [`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. |
|
||||
| [`scripts/install.sh`](scripts/install.sh) | **Host Installer**: Bootstraps directories, config template, initial administrator, and systemd service unit. |
|
||||
| [`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). |
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
- **MIT License** ([`LICENSE-MIT`](LICENSE-MIT))
|
||||
- **Apache License, Version 2.0** ([`LICENSE-APACHE`](LICENSE-APACHE))
|
||||
|
||||
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)
|
||||
|
||||
## 17. Web UI Screenshots
|
||||
|
||||
The following screenshots provide visual evidence of the production Web UI and its native
|
||||
WireGuard/network administration workflow. Sensitive endpoint and cryptographic values in
|
||||
the captured evidence have been redacted where applicable.
|
||||
|
||||
### Dashboard
|
||||
|
||||

|
||||
|
||||
### Interfaces
|
||||
|
||||

|
||||
|
||||
### Peer Management
|
||||
|
||||

|
||||
|
||||
### New Peer Enrollment
|
||||
|
||||

|
||||
|
||||
### Client Configuration Export
|
||||
|
||||

|
||||
|
||||
### QR Code Export
|
||||
|
||||

|
||||
|
||||
### Networks
|
||||
|
||||

|
||||
|
||||
### IP Forwarding
|
||||
|
||||

|
||||
|
||||
### NAT & Masquerade
|
||||
|
||||

|
||||
|
||||
### Reconciliation
|
||||
|
||||

|
||||
|
||||
### Diagnostics — System, Network & WAN
|
||||
|
||||

|
||||
|
||||
### Diagnostics — Firewall, NAT, MTU & Reconciliation
|
||||
|
||||

|
||||
|
||||
### Settings
|
||||
|
||||

|
||||
|
||||
### Backups
|
||||
|
||||

|
||||
|
||||
> **Additional evidence:** `screenshots/admin-instance.png` contains the captured administrative
|
||||
> instance documentation and is retained in the repository alongside the UI screenshots.
|
||||
- [Development Guide](docs/DEVELOPMENT.md)
|
||||
- [Docker Deployment](docs/DOCKER.md)
|
||||
@@ -23,7 +23,7 @@ reconciliation_interval_secs = 60
|
||||
dir = "/var/lib/nx9-wg/backups"
|
||||
|
||||
# Maximum number of automated backup snapshots to retain
|
||||
max_count = 10
|
||||
max_count = 5
|
||||
|
||||
# Optional cron schedule for automated database backups (e.g. "0 2 * * *" for 02:00 UTC)
|
||||
# schedule = "0 2 * * *"
|
||||
|
||||
@@ -325,7 +325,12 @@ impl DiagnosticsService {
|
||||
stats.listen_port,
|
||||
stats.peers.len()
|
||||
),
|
||||
expected_value: Some(format!("port {}", iface.listen_port)),
|
||||
expected_value: Some(
|
||||
iface
|
||||
.listen_port
|
||||
.map(|p| format!("port {p}"))
|
||||
.unwrap_or_else(|| "port auto".to_string()),
|
||||
),
|
||||
diagnostic_message: format!(
|
||||
"Interface '{}' is running and responsive",
|
||||
iface.name
|
||||
|
||||
@@ -20,6 +20,7 @@ pub use error::{ApiError, ApiResult, ErrorBody, ErrorResponse};
|
||||
pub use profile_resolver::ClientProfileResolver;
|
||||
pub use reconciliation::{
|
||||
ReconciliationAction, ReconciliationEngine, ReconciliationPlan, ReconciliationReport,
|
||||
collect_managed_wg_subnets,
|
||||
};
|
||||
pub use routes::build_api_router;
|
||||
pub use state::{AppState, SystemEvent};
|
||||
@@ -5,7 +5,8 @@ use crate::state::{AppState, SystemEvent};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::audit::AuditEventType;
|
||||
use nx9_wg_core::types::wireguard::PeerState;
|
||||
use nx9_wg_core::types::wireguard::{InterfaceRole, PeerState};
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::NetworkEngine;
|
||||
use nx9_wireguard::WireGuardEngine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,21 +28,43 @@ fn matches_ipnet(live_addrs: &[String], desired: &IpNet) -> bool {
|
||||
fn matches_allowed_ips(live_allowed_ips: &[String], desired_str: &str) -> bool {
|
||||
let desired_nets: std::collections::BTreeSet<IpNet> = desired_str
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||
.filter_map(|s| s.trim().parse::<IpNet>().ok())
|
||||
.collect();
|
||||
|
||||
let live_nets: std::collections::BTreeSet<IpNet> = live_allowed_ips
|
||||
.iter()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||
.filter_map(|s| s.trim().parse::<IpNet>().ok())
|
||||
.collect();
|
||||
|
||||
desired_nets == live_nets
|
||||
}
|
||||
|
||||
/// Collect Interface CIDRs plus enabled Subnet Network CIDRs for NAT/forwarding.
|
||||
///
|
||||
/// Only `InterfaceRole::Overlay` interfaces and peer-allocation Networks are collected
|
||||
/// for client WAN NAT. Upstream interface addresses are not included.
|
||||
pub async fn collect_managed_wg_subnets(store: &Store) -> ApiResult<Vec<IpNet>> {
|
||||
let mut subnets = Vec::new();
|
||||
|
||||
for iface in store.list_interfaces().await? {
|
||||
if !iface.enabled || iface.role != InterfaceRole::Overlay {
|
||||
continue;
|
||||
}
|
||||
subnets.push(iface.address_v4);
|
||||
if let Some(v6) = iface.address_v6 {
|
||||
subnets.push(v6);
|
||||
}
|
||||
}
|
||||
|
||||
for net in store.list_networks().await? {
|
||||
if net.enabled {
|
||||
subnets.push(net.cidr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(subnets)
|
||||
}
|
||||
|
||||
/// Individual action proposed or taken by the reconciler.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReconciliationAction {
|
||||
@@ -176,8 +199,13 @@ impl ReconciliationEngine {
|
||||
{
|
||||
drift_reasons.push("public key mismatch".to_string());
|
||||
}
|
||||
if stats.listen_port != 0 && stats.listen_port != iface.listen_port {
|
||||
drift_reasons.push("listen port mismatch".to_string());
|
||||
if let Some(desired_port) = iface.listen_port {
|
||||
if desired_port != 0
|
||||
&& stats.listen_port != 0
|
||||
&& stats.listen_port != desired_port
|
||||
{
|
||||
drift_reasons.push("listen port mismatch".to_string());
|
||||
}
|
||||
}
|
||||
if !matches_ipnet(&stats.addresses, &iface.address_v4) {
|
||||
drift_reasons
|
||||
@@ -249,7 +277,8 @@ impl ReconciliationEngine {
|
||||
|
||||
for p in &active_desired_peers {
|
||||
let pub_key_str = p.public_key.as_str();
|
||||
let desired_server_allowed = p.server_wireguard_allowed_ips();
|
||||
let desired_server_allowed =
|
||||
p.server_wireguard_allowed_ips_for_role(iface.role);
|
||||
|
||||
if let Some(live_p) = live_peers_map.get(pub_key_str) {
|
||||
// Peer is present in live kernel interface. Verify semantic drift:
|
||||
@@ -355,7 +384,25 @@ impl ReconciliationEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Routes
|
||||
// Detect orphan kernel interfaces not in desired state
|
||||
let desired_names: std::collections::HashSet<_> =
|
||||
desired_interfaces.iter().map(|i| i.name.as_str()).collect();
|
||||
for live_name in &live_interfaces {
|
||||
if !desired_names.contains(live_name.as_str()) {
|
||||
plan.actions.push(ReconciliationAction {
|
||||
subsystem: "wireguard".to_string(),
|
||||
resource_id: live_name.clone(),
|
||||
action_type: "delete_orphan_interface".to_string(),
|
||||
description: format!(
|
||||
"Orphan WireGuard interface '{}' exists in kernel but not in desired state; remove",
|
||||
live_name
|
||||
),
|
||||
});
|
||||
plan.interface_changes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Routes (SQLite Routes table only; peer-allocation Networks are not routes)
|
||||
let desired_routes = self.state.store.list_routes().await?;
|
||||
let enabled_routes: Vec<_> = desired_routes.iter().filter(|r| r.enabled).collect();
|
||||
let has_route_drift = self
|
||||
@@ -401,15 +448,7 @@ impl ReconciliationEngine {
|
||||
.map(|s| s.value == "true" || s.value == "1")
|
||||
.unwrap_or(true);
|
||||
|
||||
let mut wg_subnets = Vec::new();
|
||||
for iface in &desired_interfaces {
|
||||
if iface.enabled {
|
||||
wg_subnets.push(iface.address_v4);
|
||||
if let Some(v6) = iface.address_v6 {
|
||||
wg_subnets.push(v6);
|
||||
}
|
||||
}
|
||||
}
|
||||
let wg_subnets = collect_managed_wg_subnets(&self.state.store).await?;
|
||||
|
||||
let expected_ruleset = nx9_wg_network::NftablesRulesetBuilder::build(
|
||||
&resolved_fw_rules,
|
||||
@@ -481,10 +520,21 @@ impl ReconciliationEngine {
|
||||
}
|
||||
|
||||
let desired_interfaces = self.state.store.list_interfaces().await?;
|
||||
// Safety: refuse to orphan-cleanup if desired state appears empty
|
||||
// while live kernel interfaces exist.
|
||||
if desired_interfaces.is_empty() {
|
||||
let live_check = self.wg_engine.list_interfaces().await.unwrap_or_default();
|
||||
if !live_check.is_empty() {
|
||||
return Err(ApiError::Internal(
|
||||
"Reconciliation aborted: desired state is empty but live kernel interfaces \
|
||||
exist. This may indicate a database read failure."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut details = Vec::new();
|
||||
|
||||
// 1. Sync all active WireGuard interfaces and their peers
|
||||
let mut wg_subnets = Vec::new();
|
||||
for iface in &desired_interfaces {
|
||||
if iface.enabled {
|
||||
let peers = self.state.store.list_peers_for_interface(iface.id).await?;
|
||||
@@ -497,10 +547,6 @@ impl ReconciliationEngine {
|
||||
iface.name
|
||||
))
|
||||
})?;
|
||||
wg_subnets.push(iface.address_v4);
|
||||
if let Some(v6) = iface.address_v6 {
|
||||
wg_subnets.push(v6);
|
||||
}
|
||||
details.push(format!(
|
||||
"Synchronized interface '{}' with {} peers",
|
||||
iface.name,
|
||||
@@ -515,7 +561,29 @@ impl ReconciliationEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync Routes
|
||||
// Remove orphan kernel WireGuard interfaces absent from desired state
|
||||
let desired_names: std::collections::HashSet<_> =
|
||||
desired_interfaces.iter().map(|i| i.name.as_str()).collect();
|
||||
let live_interfaces = self.wg_engine.list_interfaces().await.unwrap_or_default();
|
||||
for live_name in &live_interfaces {
|
||||
if !desired_names.contains(live_name.as_str()) {
|
||||
match self.wg_engine.delete_interface(live_name).await {
|
||||
Ok(()) => {
|
||||
details.push(format!("Removed orphan kernel interface '{}'", live_name));
|
||||
}
|
||||
Err(e) => {
|
||||
details.push(format!(
|
||||
"Failed to remove orphan kernel interface '{}': {e}",
|
||||
live_name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let wg_subnets = collect_managed_wg_subnets(&self.state.store).await?;
|
||||
|
||||
// 2. Sync Routes (SQLite Routes table only; peer-allocation Networks are not routes)
|
||||
let routes = self.state.store.list_routes().await?;
|
||||
self.net_engine
|
||||
.sync_routes(&routes)
|
||||
|
||||
@@ -109,6 +109,9 @@
|
||||
<a href="#live-state" class="nav-link" onclick="navigateTo('live-state')">
|
||||
<span class="nav-icon">📡</span> Live State
|
||||
</a>
|
||||
<a href="#cli-console" class="nav-link" onclick="navigateTo('cli-console')">
|
||||
<span class="nav-icon">💻</span> CLI Console
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Administration Navigation -->
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! WireGuard Interface HTTP handlers.
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::routes::auth::GenericSuccess;
|
||||
use crate::state::{AppState, SystemEvent};
|
||||
@@ -7,16 +5,20 @@ use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, WireGuardPrivateKey, WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_core::validation::{
|
||||
validate_cidr, validate_interface_name, validate_listen_port, validate_mtu,
|
||||
};
|
||||
use nx9_wireguard::UpstreamConfigParser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateInterfaceRequest {
|
||||
pub name: String,
|
||||
pub role: Option<InterfaceRole>,
|
||||
pub listen_port: Option<u16>,
|
||||
pub address_v4: String,
|
||||
pub address_v6: Option<String>,
|
||||
@@ -30,6 +32,54 @@ pub struct CreateInterfaceRequest {
|
||||
pub post_down: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpstreamPreviewRequest {
|
||||
pub name: String,
|
||||
pub config: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpstreamPreviewResponse {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub address_v4: String,
|
||||
pub address_v6: Option<String>,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub listen_port: Option<u16>,
|
||||
pub peer_count: usize,
|
||||
pub provider_public_key: String,
|
||||
pub provider_endpoint: String,
|
||||
pub provider_allowed_ips: String,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
pub preshared_key_configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpstreamImportRequest {
|
||||
pub name: String,
|
||||
pub config: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpstreamImportResponse {
|
||||
pub interface_id: Uuid,
|
||||
pub peer_id: Uuid,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub address_v4: String,
|
||||
pub address_v6: Option<String>,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub listen_port: Option<u16>,
|
||||
pub provider_public_key: String,
|
||||
pub provider_endpoint: String,
|
||||
pub provider_allowed_ips: String,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
pub preshared_key_configured: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateInterfaceRequest {
|
||||
pub name: Option<String>,
|
||||
@@ -66,6 +116,30 @@ pub async fn create_interface_handler(
|
||||
Json(payload): Json<CreateInterfaceRequest>,
|
||||
) -> ApiResult<Json<Interface>> {
|
||||
validate_interface_name(&payload.name)?;
|
||||
let role = payload.role.unwrap_or(if payload.name == "wg0" {
|
||||
InterfaceRole::Overlay
|
||||
} else {
|
||||
InterfaceRole::Upstream
|
||||
});
|
||||
|
||||
if role == InterfaceRole::Overlay {
|
||||
let existing = state.store.list_interfaces().await?;
|
||||
if existing.iter().any(|i| i.role == InterfaceRole::Overlay) {
|
||||
return Err(ApiError::Conflict(
|
||||
"Only one Overlay interface ('wg0') is permitted".to_string(),
|
||||
));
|
||||
}
|
||||
if payload.name != "wg0" {
|
||||
return Err(ApiError::Validation(
|
||||
"The primary overlay interface must be named 'wg0'".to_string(),
|
||||
));
|
||||
}
|
||||
} else if payload.name == "wg0" {
|
||||
return Err(ApiError::Validation(
|
||||
"An Upstream interface cannot use the reserved name 'wg0'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let address_v4 = validate_cidr(&payload.address_v4)?;
|
||||
let address_v6 = match payload.address_v6.as_deref() {
|
||||
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
|
||||
@@ -73,8 +147,14 @@ pub async fn create_interface_handler(
|
||||
};
|
||||
|
||||
let listen_port = match payload.listen_port {
|
||||
Some(p) => validate_listen_port(p)?,
|
||||
None => 51820,
|
||||
Some(p) => Some(validate_listen_port(p)?),
|
||||
None => {
|
||||
if role == InterfaceRole::Overlay {
|
||||
Some(51820)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(m) = payload.mtu {
|
||||
@@ -93,6 +173,7 @@ pub async fn create_interface_handler(
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: payload.name,
|
||||
role,
|
||||
private_key: priv_k,
|
||||
public_key: pub_k,
|
||||
listen_port,
|
||||
@@ -119,6 +200,100 @@ pub async fn create_interface_handler(
|
||||
Ok(Json(iface))
|
||||
}
|
||||
|
||||
/// POST /api/v1/interfaces/upstreams/preview
|
||||
pub async fn preview_upstream_handler(
|
||||
Json(payload): Json<UpstreamPreviewRequest>,
|
||||
) -> ApiResult<Json<UpstreamPreviewResponse>> {
|
||||
let parsed = UpstreamConfigParser::parse(&payload.config, &payload.name)
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(Json(UpstreamPreviewResponse {
|
||||
name: parsed.interface_name,
|
||||
role: "upstream".to_string(),
|
||||
address_v4: parsed.address_v4.to_string(),
|
||||
address_v6: parsed.address_v6.map(|ip| ip.to_string()),
|
||||
dns: parsed.dns,
|
||||
mtu: parsed.mtu,
|
||||
listen_port: parsed.listen_port,
|
||||
peer_count: 1,
|
||||
provider_public_key: parsed.peer.public_key.as_str().to_string(),
|
||||
provider_endpoint: parsed.peer.endpoint,
|
||||
provider_allowed_ips: parsed.peer.allowed_ips,
|
||||
persistent_keepalive: parsed.peer.persistent_keepalive,
|
||||
preshared_key_configured: parsed.peer.preshared_key.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/interfaces/upstreams/import
|
||||
pub async fn import_upstream_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpstreamImportRequest>,
|
||||
) -> ApiResult<Json<UpstreamImportResponse>> {
|
||||
let parsed = UpstreamConfigParser::parse(&payload.config, &payload.name)
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
|
||||
// Check for interface name collision
|
||||
if state
|
||||
.store
|
||||
.get_interface_by_name(&parsed.interface_name)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"An interface named '{}' already exists",
|
||||
parsed.interface_name
|
||||
)));
|
||||
}
|
||||
|
||||
let interface_id = Uuid::new_v4();
|
||||
let peer_id = Uuid::new_v4();
|
||||
let psk_configured = parsed.peer.preshared_key.is_some();
|
||||
let (iface, peer) = parsed.into_desired_state(interface_id, peer_id);
|
||||
|
||||
// Persist desired state transactionally
|
||||
state.store.create_interface(&iface).await?;
|
||||
if let Err(e) = state.store.create_peer(&peer).await {
|
||||
let _ = state.store.delete_interface(iface.id).await;
|
||||
return Err(ApiError::from(e));
|
||||
}
|
||||
|
||||
// Synchronize to kernel / runtime state
|
||||
if let Err(e) = state
|
||||
.wg_engine
|
||||
.sync_interface(&iface, &[peer.clone()])
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
interface = %iface.name,
|
||||
error = %e,
|
||||
"Kernel sync failed after upstream import"
|
||||
);
|
||||
}
|
||||
|
||||
state.broadcast(SystemEvent::InterfaceChanged {
|
||||
id: iface.id.to_string(),
|
||||
action: "imported".to_string(),
|
||||
});
|
||||
|
||||
Ok(Json(UpstreamImportResponse {
|
||||
interface_id: iface.id,
|
||||
peer_id: peer.id,
|
||||
name: iface.name,
|
||||
role: iface.role.to_string(),
|
||||
address_v4: iface.address_v4.to_string(),
|
||||
address_v6: iface.address_v6.map(|ip| ip.to_string()),
|
||||
dns: iface.dns,
|
||||
mtu: iface.mtu,
|
||||
listen_port: iface.listen_port,
|
||||
provider_public_key: peer.public_key.as_str().to_string(),
|
||||
provider_endpoint: peer.endpoint.unwrap_or_default(),
|
||||
provider_allowed_ips: peer.allowed_ips,
|
||||
persistent_keepalive: peer.persistent_keepalive,
|
||||
preshared_key_configured: psk_configured,
|
||||
enabled: iface.enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/interfaces/{id}
|
||||
pub async fn get_interface_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -160,7 +335,7 @@ pub async fn update_interface_handler(
|
||||
}
|
||||
if let Some(port) = payload.listen_port {
|
||||
validate_listen_port(port)?;
|
||||
iface.listen_port = port;
|
||||
iface.listen_port = Some(port);
|
||||
}
|
||||
if let Some(ref v4) = payload.address_v4 {
|
||||
iface.address_v4 = validate_cidr(v4)?;
|
||||
@@ -216,6 +391,22 @@ pub async fn delete_interface_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<GenericSuccess>> {
|
||||
let iface = state
|
||||
.store
|
||||
.get_interface(id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
||||
|
||||
if iface.name == "wg0" {
|
||||
return Err(ApiError::Forbidden(
|
||||
"The primary overlay interface 'wg0' cannot be deleted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 1. Attempt kernel deletion
|
||||
let _ = state.wg_engine.delete_interface(&iface.name).await;
|
||||
|
||||
// 2. Delete from DB
|
||||
state.store.delete_interface(id).await?;
|
||||
|
||||
state.broadcast(SystemEvent::InterfaceChanged {
|
||||
@@ -252,6 +443,18 @@ pub async fn disable_interface_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<GenericSuccess>> {
|
||||
let iface = state
|
||||
.store
|
||||
.get_interface(id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
||||
|
||||
if iface.name == "wg0" {
|
||||
return Err(ApiError::Forbidden(
|
||||
"The primary overlay interface 'wg0' cannot be disabled".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.store.set_interface_enabled(id, false).await?;
|
||||
|
||||
state.broadcast(SystemEvent::InterfaceChanged {
|
||||
@@ -288,3 +491,38 @@ pub async fn interface_status_handler(
|
||||
active_peer_count: active_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/interfaces/{id}/restart
|
||||
pub async fn restart_interface_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<GenericSuccess>> {
|
||||
let iface = state
|
||||
.store
|
||||
.get_interface(id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
||||
|
||||
// 1. Tear down the kernel WireGuard interface
|
||||
let _ = state.wg_engine.delete_interface(&iface.name).await;
|
||||
|
||||
// 2. Re-sync from desired state (recreate link, addresses, peers, routes)
|
||||
let peers = state.store.list_peers_for_interface(iface.id).await?;
|
||||
state
|
||||
.wg_engine
|
||||
.sync_interface(&iface, &peers)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!("Failed to restart interface '{}': {e}", iface.name))
|
||||
})?;
|
||||
|
||||
state.broadcast(SystemEvent::InterfaceChanged {
|
||||
id: iface.id.to_string(),
|
||||
action: "restarted".to_string(),
|
||||
});
|
||||
|
||||
Ok(Json(GenericSuccess {
|
||||
success: true,
|
||||
message: format!("Interface '{}' restarted successfully", iface.name),
|
||||
}))
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod backups;
|
||||
pub mod cli;
|
||||
pub mod client_profiles;
|
||||
pub mod diagnostics;
|
||||
pub mod firewall;
|
||||
@@ -38,9 +39,19 @@ pub fn build_api_router(state: AppState) -> Router {
|
||||
.route("/system/live-state", get(system::live_state_handler))
|
||||
.route("/system/settings", get(system::list_settings_handler))
|
||||
.route("/system/settings", put(system::upsert_setting_handler))
|
||||
.route("/system/cli", post(cli::execute_cli_handler))
|
||||
.route("/system/cli/commands", get(cli::list_cli_commands_handler))
|
||||
// Interfaces
|
||||
.route("/interfaces", get(interfaces::list_interfaces_handler))
|
||||
.route("/interfaces", post(interfaces::create_interface_handler))
|
||||
.route(
|
||||
"/interfaces/upstreams/preview",
|
||||
post(interfaces::preview_upstream_handler),
|
||||
)
|
||||
.route(
|
||||
"/interfaces/upstreams/import",
|
||||
post(interfaces::import_upstream_handler),
|
||||
)
|
||||
.route("/interfaces/{id}", get(interfaces::get_interface_handler))
|
||||
.route(
|
||||
"/interfaces/{id}",
|
||||
@@ -58,6 +69,10 @@ pub fn build_api_router(state: AppState) -> Router {
|
||||
"/interfaces/{id}/disable",
|
||||
post(interfaces::disable_interface_handler),
|
||||
)
|
||||
.route(
|
||||
"/interfaces/{id}/restart",
|
||||
post(interfaces::restart_interface_handler),
|
||||
)
|
||||
.route(
|
||||
"/interfaces/{id}/status",
|
||||
get(interfaces::interface_status_handler),
|
||||
|
||||
@@ -16,15 +16,47 @@ use nx9_wg_core::types::wireguard::{
|
||||
WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_core::validation::{validate_cidr, validate_mtu, validate_peer_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Deserialize `network_id` from JSON null/empty as None, and from a UUID string as Some.
|
||||
/// Rejects non-UUID values instead of silently falling back to the Interface CIDR.
|
||||
fn deserialize_optional_network_id<'de, D>(deserializer: D) -> Result<Option<Uuid>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
match value {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Uuid::parse_str(trimmed).map(Some).map_err(|e| {
|
||||
serde::de::Error::custom(format!("network_id must be a Network UUID: {e}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
Some(other) => Err(serde::de::Error::custom(format!(
|
||||
"network_id must be a UUID string, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePeerRequest {
|
||||
pub name: String,
|
||||
pub peer_type: Option<PeerType>,
|
||||
pub profile: Option<PeerProfile>,
|
||||
/// Subnet Network UUID for IP allocation. Also accepts the historical
|
||||
/// enrollment field name `network` when that value is a UUID.
|
||||
#[serde(
|
||||
default,
|
||||
alias = "network",
|
||||
deserialize_with = "deserialize_optional_network_id"
|
||||
)]
|
||||
pub network_id: Option<Uuid>,
|
||||
pub public_key: Option<String>,
|
||||
pub private_key: Option<String>,
|
||||
@@ -264,6 +296,47 @@ async fn validate_no_server_allowed_ips_conflict(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Allocate a peer IPv4 address.
|
||||
///
|
||||
/// When `network_id` is present, allocation MUST use that Network's CIDR and
|
||||
/// MUST NOT fall back to the WireGuard Interface address space.
|
||||
/// When `network_id` is absent, preserve the existing Interface CIDR fallback.
|
||||
async fn allocate_address_v4_for_peer(
|
||||
store: &nx9_wg_db::Store,
|
||||
interface: &nx9_wg_core::types::wireguard::Interface,
|
||||
network_id: Option<Uuid>,
|
||||
) -> ApiResult<IpNet> {
|
||||
match network_id {
|
||||
Some(net_id) => {
|
||||
let network = store
|
||||
.get_network(net_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Network '{net_id}' not found")))?;
|
||||
let allocated =
|
||||
IpAllocator::allocate_next_ip(store, &network, Some(interface), None).await?;
|
||||
if !network.cidr.contains(&allocated.addr()) {
|
||||
return Err(ApiError::Internal(format!(
|
||||
"allocated address {allocated} is outside selected network '{}' ({})",
|
||||
network.name, network.cidr
|
||||
)));
|
||||
}
|
||||
Ok(allocated)
|
||||
}
|
||||
None => {
|
||||
let fallback = Network {
|
||||
id: Uuid::nil(),
|
||||
name: format!("{}-subnet", interface.name),
|
||||
cidr: interface.address_v4,
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
IpAllocator::allocate_next_ip(store, &fallback, Some(interface), None).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v1/interfaces/{id}/peers
|
||||
pub async fn create_peer_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -289,28 +362,12 @@ pub async fn create_peer_handler(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// If address_v4 was not explicitly provided, automatically allocate it
|
||||
// If address_v4 was not explicitly provided, automatically allocate it.
|
||||
// A present network_id selects the Subnet Network CIDR; None keeps the
|
||||
// Interface Network CIDR fallback. These paths are intentionally separate.
|
||||
if address_v4.is_none() {
|
||||
let net = match payload.network_id {
|
||||
Some(net_id) => state
|
||||
.store
|
||||
.get_network(net_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Network '{net_id}' not found")))?,
|
||||
None => Network {
|
||||
id: Uuid::nil(),
|
||||
name: format!("{}-subnet", interface.name),
|
||||
cidr: interface.address_v4,
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
},
|
||||
};
|
||||
|
||||
let allocated =
|
||||
IpAllocator::allocate_next_ip(&state.store, &net, Some(&interface), None).await?;
|
||||
address_v4 = Some(allocated);
|
||||
address_v4 =
|
||||
Some(allocate_address_v4_for_peer(&state.store, &interface, payload.network_id).await?);
|
||||
}
|
||||
|
||||
let allowed_ips = match payload.allowed_ips {
|
||||
@@ -607,38 +664,15 @@ async fn resolve_server_endpoint(
|
||||
state: &AppState,
|
||||
query: &ClientProfileQuery,
|
||||
) -> ApiResult<String> {
|
||||
// 1. Explicit query parameter (server_endpoint or endpoint)
|
||||
if let Some(ep) = query
|
||||
let explicit_override = query
|
||||
.server_endpoint
|
||||
.as_deref()
|
||||
.or(query.endpoint.as_deref())
|
||||
{
|
||||
let trimmed = ep.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Persistent server_endpoint configuration from store
|
||||
if let Some(setting) = state.store.get_setting("server_endpoint").await? {
|
||||
let trimmed = setting.value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Persistent public_endpoint configuration from store
|
||||
if let Some(setting) = state.store.get_setting("public_endpoint").await? {
|
||||
let trimmed = setting.value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit actionable error if no reachable server endpoint is configured
|
||||
Err(ApiError::Validation(
|
||||
"No reachable WireGuard server endpoint is configured. Configure 'server_endpoint' in settings or provide --endpoint / query parameter.".to_string(),
|
||||
))
|
||||
.or(query.endpoint.as_deref());
|
||||
state
|
||||
.store
|
||||
.resolve_server_endpoint(explicit_override)
|
||||
.await
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -817,3 +851,55 @@ pub async fn get_peer_qr_handler(
|
||||
data_url,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod create_peer_request_tests {
|
||||
use super::CreatePeerRequest;
|
||||
use uuid::Uuid;
|
||||
|
||||
const NETWORK_UUID: &str = "c2aa62c7-3b9d-43fb-95e7-aa8ab1c71265";
|
||||
|
||||
#[test]
|
||||
fn ui_payload_deserializes_network_id_uuid() {
|
||||
let json = serde_json::json!({
|
||||
"name": "sunil-moto-mobile-network-01",
|
||||
"peer_type": "road_warrior",
|
||||
"profile": "full_tunnel",
|
||||
"mtu": 1280,
|
||||
"persistent_keepalive": 25,
|
||||
"dns": "1.1.1.1, 1.0.0.1",
|
||||
"allowed_ips": "0.0.0.0/0, ::/0",
|
||||
"network_id": NETWORK_UUID
|
||||
});
|
||||
let req: CreatePeerRequest = serde_json::from_value(json).expect("deserialize UI payload");
|
||||
assert_eq!(req.network_id, Some(Uuid::parse_str(NETWORK_UUID).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_network_field_uuid_maps_to_network_id() {
|
||||
let json = serde_json::json!({
|
||||
"name": "sunil-moto-mobile-network-01",
|
||||
"network": NETWORK_UUID
|
||||
});
|
||||
let req: CreatePeerRequest =
|
||||
serde_json::from_value(json).expect("deserialize historical network field");
|
||||
assert_eq!(req.network_id, Some(Uuid::parse_str(NETWORK_UUID).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_network_id_deserializes_as_none() {
|
||||
let json = serde_json::json!({
|
||||
"name": "bob-fallback",
|
||||
"network_id": null
|
||||
});
|
||||
let req: CreatePeerRequest = serde_json::from_value(json).expect("deserialize null");
|
||||
assert_eq!(req.network_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_network_id_deserializes_as_none() {
|
||||
let json = serde_json::json!({ "name": "bob-fallback" });
|
||||
let req: CreatePeerRequest = serde_json::from_value(json).expect("deserialize missing");
|
||||
assert_eq!(req.network_id, None);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,28 @@ pub async fn upsert_setting_handler(
|
||||
"Invalid server endpoint '{val_trimmed}'. Endpoint must be formatted as host:port (e.g. 192.168.1.8:51820 or vpn.domain.com:51820)"
|
||||
)));
|
||||
}
|
||||
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_HOST {
|
||||
if !val_trimmed.is_empty() {
|
||||
nx9_wg_core::validation::validate_server_host(val_trimmed)
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
}
|
||||
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_PORT {
|
||||
let port: u16 = val_trimmed.parse().map_err(|_| {
|
||||
ApiError::Validation(
|
||||
"Invalid server port: must be an integer between 1 and 65535".to_string(),
|
||||
)
|
||||
})?;
|
||||
nx9_wg_core::validation::validate_server_port(port)
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_ENDPOINT_ENABLED
|
||||
&& val_trimmed != "true"
|
||||
&& val_trimmed != "false"
|
||||
&& val_trimmed != "1"
|
||||
&& val_trimmed != "0"
|
||||
{
|
||||
return Err(ApiError::Validation(
|
||||
"Setting wireguard.server_endpoint_enabled must be 'true' or 'false'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_secret = payload.is_secret.unwrap_or(false);
|
||||
@@ -129,6 +151,25 @@ pub async fn upsert_setting_handler(
|
||||
.set_setting(key_trimmed, val_trimmed, is_secret)
|
||||
.await?;
|
||||
|
||||
// If updating server host/port/enabled, also keep legacy server_endpoint in sync if valid
|
||||
if (key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_HOST
|
||||
|| key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_PORT
|
||||
|| key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_ENDPOINT_ENABLED)
|
||||
&& let Ok(settings) = state.store.get_server_endpoint_settings().await
|
||||
&& settings.enabled
|
||||
&& !settings.host.trim().is_empty()
|
||||
{
|
||||
let formatted = nx9_wg_core::validation::format_endpoint(&settings.host, settings.port);
|
||||
let _ = state
|
||||
.store
|
||||
.set_setting(
|
||||
nx9_wg_core::types::settings::LEGACY_SETTING_SERVER_ENDPOINT,
|
||||
&formatted,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
state.broadcast(SystemEvent::SettingsChanged {
|
||||
key: key_trimmed.to_string(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::header::COOKIE;
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
@@ -14,24 +16,66 @@ pub struct WsAuthQuery {
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract the NX9 session identifier from the browser session cookie.
|
||||
///
|
||||
/// The WebUI authenticates through the HttpOnly `nx9_session` cookie.
|
||||
/// WebSocket upgrades do not pass through the normal REST authentication
|
||||
/// middleware, so the cookie must be authenticated explicitly here.
|
||||
fn extract_session_cookie(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|cookie| cookie.strip_prefix("nx9_session="))
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|session_id| !session_id.is_empty())
|
||||
}
|
||||
|
||||
/// Authenticate a WebSocket request.
|
||||
///
|
||||
/// Authentication precedence:
|
||||
///
|
||||
/// 1. Explicit API token: `?token=...`
|
||||
/// 2. Explicit session: `?session=...`
|
||||
/// 3. Browser session cookie: `nx9_session=...`
|
||||
///
|
||||
/// The browser WebUI uses the HttpOnly session cookie, so no credential
|
||||
/// needs to be exposed in the WebSocket URL.
|
||||
async fn authenticate_websocket(
|
||||
state: &AppState,
|
||||
query: &WsAuthQuery,
|
||||
headers: &HeaderMap,
|
||||
) -> bool {
|
||||
if let Some(raw_token) = query.token.as_deref() {
|
||||
return state.auth.authenticate_token(raw_token).await.is_ok();
|
||||
}
|
||||
|
||||
if let Some(session_id) = query.session.as_deref() {
|
||||
return state.auth.authenticate_session(session_id).await.is_ok();
|
||||
}
|
||||
|
||||
if let Some(session_id) = extract_session_cookie(headers) {
|
||||
return state.auth.authenticate_session(session_id).await.is_ok();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// GET /api/v1/ws
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<WsAuthQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Authenticate WebSocket connection via query parameters
|
||||
let authenticated = if let Some(ref raw_token) = query.token {
|
||||
state.auth.authenticate_token(raw_token).await.is_ok()
|
||||
} else if let Some(ref session_id) = query.session {
|
||||
state.auth.authenticate_session(session_id).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !authenticated {
|
||||
if !authenticate_websocket(&state, &query, &headers).await {
|
||||
return Err(ApiError::Unauthenticated(
|
||||
"WebSocket authentication required. Supply ?token=... or ?session=...".to_string(),
|
||||
"WebSocket authentication required. Supply a valid API token, session, or nx9_session cookie."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -42,11 +86,12 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let mut rx = state.event_tx.subscribe();
|
||||
|
||||
// Spawn background task to stream broadcast events to client
|
||||
// Stream broadcast events to the connected WebSocket client.
|
||||
let mut send_task = tokio::spawn(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
let msg = Message::Text(json.into());
|
||||
|
||||
if sender.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -54,7 +99,7 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
});
|
||||
|
||||
// Client receive loop to handle close/ping/pong
|
||||
// Receive loop handles client close frames and keeps the connection alive.
|
||||
let mut recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
if let Message::Close(_) = msg {
|
||||
@@ -63,9 +108,13 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
});
|
||||
|
||||
// If either task exits, abort the other
|
||||
// If either side terminates, stop the other task.
|
||||
tokio::select! {
|
||||
_ = (&mut send_task) => recv_task.abort(),
|
||||
_ = (&mut recv_task) => send_task.abort(),
|
||||
_ = (&mut send_task) => {
|
||||
recv_task.abort();
|
||||
}
|
||||
_ = (&mut recv_task) => {
|
||||
send_task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ use ipnet::IpNet;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, ResolvedClientProfile};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use std::str::FromStr;
|
||||
use tower::ServiceExt;
|
||||
@@ -38,9 +40,10 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
||||
let interface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Comprehensive Integration test suite for Interface Lifecycle Hardening:
|
||||
//! - Interface deletion converges desired state and kernel state
|
||||
//! - wg0 protection (deletion and disabling rejected via API & CLI)
|
||||
//! - Reconciliation orphan detection and cleanup
|
||||
//! - Desired-state read failure safety guard
|
||||
//! - Interface restart lifecycle
|
||||
//! - SPA Read-Only CLI Console allowlist and safety
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::routes::cli::{ExecuteCliRequest, build_safe_argv, scrub_secrets};
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wireguard::{LiveInterfaceStats, SimulatedWireGuardEngine, WireGuardEngine};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn setup_test_context() -> (
|
||||
TempDir,
|
||||
Store,
|
||||
AppState,
|
||||
Arc<SimulatedWireGuardEngine>,
|
||||
Arc<SimulatedNetworkEngine>,
|
||||
ReconciliationEngine,
|
||||
axum::Router,
|
||||
String,
|
||||
) {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let db_path = dir.path().join("lifecycle_test.db");
|
||||
let store = Store::connect(&db_path.to_string_lossy())
|
||||
.await
|
||||
.expect("connect to db");
|
||||
store.migrate().await.expect("run migrations");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("AdminSecret123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
let state = AppState::with_engines(store.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Login to get session ID
|
||||
let login_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "AdminSecret123!"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(login_req).await.expect("login request");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let cookie_header = resp
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.expect("set-cookie")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
|
||||
|
||||
(
|
||||
dir,
|
||||
store,
|
||||
state,
|
||||
wg_engine,
|
||||
net_engine,
|
||||
reconciler,
|
||||
app,
|
||||
session_cookie,
|
||||
)
|
||||
}
|
||||
|
||||
fn fixture_interface(name: &str, v4_cidr: &str) -> Interface {
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let now = Utc::now().naive_utc();
|
||||
Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_k,
|
||||
public_key: pub_k,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr(v4_cidr).unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_peer(iface_id: Uuid, name: &str, v4_addr: &str) -> Peer {
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let now = Utc::now().naive_utc();
|
||||
Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface_id,
|
||||
name: name.to_string(),
|
||||
public_key: pub_k,
|
||||
preshared_key: None,
|
||||
private_key: Some(priv_k),
|
||||
endpoint: None,
|
||||
address_v4: Some(validate_cidr(v4_addr).unwrap()),
|
||||
address_v6: None,
|
||||
allowed_ips: "0.0.0.0/0".to_string(),
|
||||
server_allowed_ips: None,
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
persistent_keepalive: Some(25),
|
||||
mtu: Some(1420),
|
||||
state: PeerState::Active,
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
last_handshake_at: None,
|
||||
expires_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interface_delete_removes_kernel_state() {
|
||||
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create desired interface
|
||||
let iface = fixture_interface("custom0", "10.200.0.1/24");
|
||||
store
|
||||
.create_interface(&iface)
|
||||
.await
|
||||
.expect("create interface");
|
||||
|
||||
// 2. Sync to simulated kernel
|
||||
wg_engine.sync_interface(&iface, &[]).await.expect("sync");
|
||||
|
||||
// 3. Verify kernel interface exists
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"custom0".to_string()));
|
||||
|
||||
// 4. Delete via API
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{}", iface.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 5. Verify DB object removed
|
||||
let db_iface = store.get_interface(iface.id).await.unwrap();
|
||||
assert!(db_iface.is_none());
|
||||
|
||||
// 6. Verify kernel interface removed
|
||||
let live_after = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(!live_after.contains(&"custom0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wg0_deletion_rejected() {
|
||||
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create wg0 interface
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0");
|
||||
|
||||
// 2. Attempt deletion via API
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{}", wg0.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(val["error"]["message"].as_str().unwrap().contains("wg0"));
|
||||
|
||||
// 3. Confirm DB and kernel state remain intact
|
||||
let db_wg0 = store.get_interface(wg0.id).await.unwrap();
|
||||
assert!(db_wg0.is_some());
|
||||
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"wg0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wg0_disable_rejected() {
|
||||
let (_dir, store, _state, _wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create wg0 interface
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
|
||||
// 2. Attempt disable via API
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{}/disable", wg0.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
// 3. Confirm enabled remains true in DB
|
||||
let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap();
|
||||
assert!(db_wg0.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_orphan_interface_reconciliation() {
|
||||
let (_dir, store, _state, wg_engine, _net, reconciler, _app, _cookie) =
|
||||
setup_test_context().await;
|
||||
|
||||
// 1. Create desired interface wg0
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0");
|
||||
|
||||
// 2. Inject orphan kernel-only interface (e.g. proton0)
|
||||
wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "proton0".to_string(),
|
||||
public_key: "OrphanPubKey123456789012345678901234567890=".to_string(),
|
||||
listen_port: 51821,
|
||||
fwmark: 0,
|
||||
addresses: vec!["10.2.0.2/32".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
peers: vec![],
|
||||
})
|
||||
.await;
|
||||
|
||||
// 3. Verify kernel has both wg0 and proton0
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"wg0".to_string()));
|
||||
assert!(live.contains(&"proton0".to_string()));
|
||||
|
||||
// 4. Run reconciliation plan
|
||||
let plan = reconciler.plan().await.expect("plan");
|
||||
assert!(plan.has_drift);
|
||||
let orphan_action = plan
|
||||
.actions
|
||||
.iter()
|
||||
.find(|a| a.action_type == "delete_orphan_interface" && a.resource_id == "proton0");
|
||||
assert!(
|
||||
orphan_action.is_some(),
|
||||
"Expected orphan removal action for proton0"
|
||||
);
|
||||
|
||||
// 5. Run reconciliation apply
|
||||
let report = reconciler.apply().await.expect("apply");
|
||||
assert!(report.success);
|
||||
|
||||
// 6. Confirm kernel interface proton0 is removed, wg0 remains
|
||||
let live_after = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live_after.contains(&"wg0".to_string()));
|
||||
assert!(!live_after.contains(&"proton0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interface_restart_preserves_state() {
|
||||
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create interface with peer
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
let peer = fixture_peer(wg0.id, "mobile-alice", "10.100.0.5/32");
|
||||
store.create_peer(&peer).await.expect("create peer");
|
||||
|
||||
// 2. Initial sync
|
||||
wg_engine
|
||||
.sync_interface(&wg0, &[peer.clone()])
|
||||
.await
|
||||
.expect("sync");
|
||||
|
||||
// 3. Call restart API
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{}/restart", wg0.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 4. Verify DB object remains identical
|
||||
let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap();
|
||||
assert_eq!(db_wg0.id, wg0.id);
|
||||
assert_eq!(db_wg0.name, "wg0");
|
||||
assert_eq!(db_wg0.address_v4, wg0.address_v4);
|
||||
assert_eq!(db_wg0.public_key.as_str(), wg0.public_key.as_str());
|
||||
|
||||
// 5. Verify live kernel state converged with peer restored
|
||||
let stats = wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("wg0 stats");
|
||||
assert_eq!(stats.name, "wg0");
|
||||
assert_eq!(stats.peers.len(), 1);
|
||||
assert_eq!(stats.peers[0].public_key, peer.public_key.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reconcile_does_not_delete_on_desired_state_read_failure() {
|
||||
let (_dir, _store, state, wg_engine, net_engine, _rec, _app, _cookie) =
|
||||
setup_test_context().await;
|
||||
|
||||
// 1. Inject live interface in kernel
|
||||
wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "wg0".to_string(),
|
||||
public_key: "Wg0PubKey12345678901234567890123456789012=".to_string(),
|
||||
listen_port: 51820,
|
||||
fwmark: 0,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
peers: vec![],
|
||||
})
|
||||
.await;
|
||||
|
||||
// 2. Desired state is empty in DB
|
||||
// Reconciler should abort rather than mass-deleting live interfaces
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let result = reconciler.apply().await;
|
||||
assert!(result.is_err(), "Expected reconciliation to abort safely");
|
||||
|
||||
// 3. Confirm live interface was NOT deleted
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"wg0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cli_console_readonly_whitelist() {
|
||||
// 1. Test allowed read-only commands
|
||||
let allowed_tests = vec![
|
||||
ExecuteCliRequest {
|
||||
command: "version".to_string(),
|
||||
subcommand: None,
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "system".to_string(),
|
||||
subcommand: Some("status".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("list".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("show".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: Some("wg0".to_string()),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "peer".to_string(),
|
||||
subcommand: Some("list".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "live".to_string(),
|
||||
subcommand: Some("interface".to_string()),
|
||||
sub_subcommand: Some("list".to_string()),
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "reconcile".to_string(),
|
||||
subcommand: Some("status".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
];
|
||||
|
||||
for req in allowed_tests {
|
||||
let argv = build_safe_argv(&req);
|
||||
assert!(
|
||||
argv.is_ok(),
|
||||
"Expected command {:?} to be allowed",
|
||||
req.command
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Test mutating commands are rejected
|
||||
let mutating_tests = vec![
|
||||
"create", "delete", "update", "set", "enable", "disable", "restart", "apply", "restore",
|
||||
"reset", "remove", "flush", "add", "sh", "bash", "sudo",
|
||||
];
|
||||
|
||||
for cmd in mutating_tests {
|
||||
let req = ExecuteCliRequest {
|
||||
command: cmd.to_string(),
|
||||
subcommand: None,
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
let argv = build_safe_argv(&req);
|
||||
assert!(
|
||||
argv.is_err(),
|
||||
"Expected mutating command '{cmd}' to be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Test shell meta characters in target are rejected
|
||||
let bad_targets = vec![
|
||||
"-option",
|
||||
"wg0; rm -rf /",
|
||||
"wg0 | ls",
|
||||
"wg0 & sleep 5",
|
||||
"wg0 `whoami`",
|
||||
"wg0 $(whoami)",
|
||||
];
|
||||
|
||||
for bad in bad_targets {
|
||||
let req = ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("show".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: Some(bad.to_string()),
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
let argv = build_safe_argv(&req);
|
||||
assert!(
|
||||
argv.is_err(),
|
||||
"Expected unsafe target '{bad}' to be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Test secrets scrubbing
|
||||
let raw_text = r#"
|
||||
Interface: wg0
|
||||
PrivateKey: aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg==
|
||||
PublicKey: dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA==
|
||||
PresharedKey: c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE=
|
||||
Addresses: 10.100.0.1/24
|
||||
"#;
|
||||
|
||||
let scrubbed = scrub_secrets(raw_text);
|
||||
assert!(!scrubbed.contains("aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg=="));
|
||||
assert!(!scrubbed.contains("c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE="));
|
||||
assert!(scrubbed.contains("[REDACTED]"));
|
||||
assert!(scrubbed.contains("10.100.0.1/24"));
|
||||
assert!(scrubbed.contains("dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA=="));
|
||||
}
|
||||
@@ -16,7 +16,9 @@ use nx9_wg_core::types::firewall::{
|
||||
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||
};
|
||||
use nx9_wg_core::types::network::Route;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
@@ -59,9 +61,10 @@ async fn test_drift_matrix_peer_lifecycle() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "nx9_test0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.10.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -267,9 +270,10 @@ async fn test_restart_recovery_simulation() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "nx9_boot".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.20.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -321,9 +325,10 @@ async fn test_secret_redaction_in_reconciliation_plan_and_report() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "nx9_sec".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.30.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -363,9 +368,10 @@ async fn test_reconciliation_status_lifecycle_and_multi_cycle_idempotency() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "nx9_idem".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.50.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -512,9 +518,10 @@ async fn test_interface_address_and_mtu_drift_lifecycle() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key.clone(),
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
|
||||
address_v6: Some(validate_cidr("fd00::1/64").unwrap()),
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::Interface;
|
||||
use nx9_wg_core::types::wireguard::{Interface, InterfaceRole};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
@@ -31,9 +31,10 @@ async fn test_reconciliation_engine_drift_detection_and_apply() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -5,7 +5,10 @@ use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wireguard::SimulatedWireGuardEngine;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn setup_test_app() -> (axum::Router, String) {
|
||||
@@ -21,7 +24,11 @@ async fn setup_test_app() -> (axum::Router, String) {
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let state = AppState::new(store);
|
||||
let state = AppState::with_engines(
|
||||
store,
|
||||
Arc::new(SimulatedWireGuardEngine::new()),
|
||||
Arc::new(SimulatedNetworkEngine::new()),
|
||||
);
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Login to get session ID
|
||||
@@ -78,6 +85,7 @@ async fn test_public_health_and_version_endpoints() {
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(val["name"], "nx9-wg");
|
||||
assert_eq!(val["version"], "1.1.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -176,14 +184,54 @@ async fn test_interfaces_and_peers_rest_lifecycle() {
|
||||
let peer_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(peer_val["state"], "disabled");
|
||||
|
||||
// 6. Delete interface (cascades peer)
|
||||
let del_iface_req = Request::builder()
|
||||
// 6. Delete wg0 interface (must be rejected with 403 Forbidden)
|
||||
let del_wg0_req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(del_iface_req).await.unwrap();
|
||||
let resp = app.clone().oneshot(del_wg0_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
// 7. Restart wg0 interface (must succeed)
|
||||
let restart_wg0_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}/restart"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(restart_wg0_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 8. Create secondary interface and delete it (must succeed)
|
||||
let create_sec_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "custom0",
|
||||
"listen_port": 51822,
|
||||
"address_v4": "10.200.0.1/24"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(create_sec_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let sec_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
let sec_id = sec_val["id"].as_str().unwrap();
|
||||
|
||||
let del_sec_req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{sec_id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(del_sec_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
@@ -409,3 +457,134 @@ async fn test_list_all_peers_collection_endpoint() {
|
||||
assert_eq!(iface1_peers.len(), 1, "wg1 must return exactly 1 peer");
|
||||
assert_eq!(iface1_peers[0]["name"], "peer-charlie");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_peer_creation_allocates_from_selected_network() {
|
||||
let (app, cookie) = setup_test_app().await;
|
||||
|
||||
// Interface Network (WireGuard transport address space)
|
||||
let create_iface_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"address_v4": "10.100.0.1/24"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let iface_val: Value =
|
||||
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
let iface_id = iface_val["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(iface_val["address_v4"], "10.100.0.1/24");
|
||||
|
||||
// Subnet Network (peer allocation domain)
|
||||
let create_net_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/networks")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "mobile-clients",
|
||||
"cidr": "10.100.2.0/24"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(create_net_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let net_val: Value =
|
||||
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
let network_id = net_val["id"].as_str().unwrap();
|
||||
assert_eq!(net_val["cidr"], "10.100.2.0/24");
|
||||
|
||||
// Exact production enrollment payload: selected Subnet Network UUID as network_id.
|
||||
let selected_peer_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "sunil-moto-mobile-network-01",
|
||||
"peer_type": "road_warrior",
|
||||
"profile": "full_tunnel",
|
||||
"mtu": 1280,
|
||||
"persistent_keepalive": 25,
|
||||
"dns": "1.1.1.1, 1.0.0.1",
|
||||
"allowed_ips": "0.0.0.0/0, ::/0",
|
||||
"network_id": network_id
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(selected_peer_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let selected_peer: Value =
|
||||
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
let selected_addr = selected_peer["address_v4"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
selected_addr, "10.100.2.1/32",
|
||||
"selected Network must allocate the first host of 10.100.2.0/24, got {selected_addr}"
|
||||
);
|
||||
assert!(
|
||||
selected_addr.starts_with("10.100.2."),
|
||||
"selected Network must allocate from 10.100.2.0/24, got {selected_addr}"
|
||||
);
|
||||
assert!(
|
||||
!selected_addr.starts_with("10.100.0."),
|
||||
"must not allocate from Interface Network 10.100.0.0/24 when a Subnet Network is selected, got {selected_addr}"
|
||||
);
|
||||
assert!(selected_addr.ends_with("/32"));
|
||||
|
||||
// network_id = null preserves existing fallback (Interface Network CIDR)
|
||||
let fallback_peer_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "bob-fallback",
|
||||
"peer_type": "road_warrior",
|
||||
"profile": "full_tunnel",
|
||||
"network_id": null
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(fallback_peer_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let fallback_peer: Value =
|
||||
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
let fallback_addr = fallback_peer["address_v4"].as_str().unwrap();
|
||||
assert!(
|
||||
fallback_addr.starts_with("10.100.0."),
|
||||
"network_id=null must preserve fallback allocation from Interface Network 10.100.0.0/24, got {fallback_addr}"
|
||||
);
|
||||
assert!(
|
||||
!fallback_addr.starts_with("10.100.2."),
|
||||
"network_id=null must not allocate from a Subnet Network, got {fallback_addr}"
|
||||
);
|
||||
assert!(fallback_addr.ends_with("/32"));
|
||||
|
||||
// WireGuard interface address space is unchanged
|
||||
let get_iface_req = Request::builder()
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(get_iface_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let iface_after: Value =
|
||||
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
assert_eq!(iface_after["name"], "wg0");
|
||||
assert_eq!(iface_after["address_v4"], "10.100.0.1/24");
|
||||
}
|
||||
@@ -5,11 +5,15 @@ use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_api::collect_managed_wg_subnets;
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
use nx9_wireguard::{
|
||||
@@ -46,9 +50,10 @@ async fn setup_test_context() -> (AppState, Interface, Peer, String) {
|
||||
let interface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.100.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -218,7 +223,7 @@ async fn test_learned_endpoint_and_handshake_telemetry_ingestion() {
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.as_str().to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
listen_port: iface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: live_peers,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
@@ -268,7 +273,7 @@ async fn test_peer_allowed_ips_and_keepalive_kernel_drift() {
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.as_str().to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
listen_port: iface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: drifted_peers,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
@@ -344,6 +349,149 @@ async fn test_forwarding_and_nat_reconciliation_invariants() {
|
||||
assert_eq!(plan.interface_changes, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_selected_network_dataplane_nat_and_routes() {
|
||||
let (state, iface, _peer, _session_id) = setup_test_context().await;
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let network = Network {
|
||||
id: Uuid::new_v4(),
|
||||
name: "mobile-clients".to_string(),
|
||||
cidr: IpNet::from_str("10.100.2.0/24").unwrap(),
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.store.create_network(&network).await.unwrap();
|
||||
|
||||
let (peer_priv, peer_pub) = generate_keypair();
|
||||
let selected_peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface.id,
|
||||
name: "test-mobile".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: peer_pub,
|
||||
private_key: Some(peer_priv),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.100.2.1/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: Some("1.1.1.1, 1.0.0.1".to_string()),
|
||||
mtu: Some(1280),
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.store.create_peer(&selected_peer).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
selected_peer.server_wireguard_allowed_ips(),
|
||||
"10.100.2.1/32",
|
||||
"server-side AllowedIPs must remain the assigned selected-Network address"
|
||||
);
|
||||
assert_eq!(selected_peer.allowed_ips, "0.0.0.0/0, ::/0");
|
||||
|
||||
let subnets = collect_managed_wg_subnets(&state.store).await.unwrap();
|
||||
assert!(
|
||||
subnets
|
||||
.iter()
|
||||
.any(|s| s.trunc().to_string() == "10.100.0.0/24"),
|
||||
"Interface CIDR must remain in managed NAT subnets"
|
||||
);
|
||||
assert!(
|
||||
subnets
|
||||
.iter()
|
||||
.any(|s| s.trunc().to_string() == "10.100.2.0/24"),
|
||||
"selected Network CIDR must participate in managed NAT subnets"
|
||||
);
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
|
||||
let report = reconciler.apply().await.unwrap();
|
||||
assert!(report.success);
|
||||
|
||||
let persisted_iface = state.store.get_interface(iface.id).await.unwrap().unwrap();
|
||||
assert_eq!(persisted_iface.address_v4.to_string(), "10.100.0.1/24");
|
||||
assert_eq!(persisted_iface.name, "wg0");
|
||||
let stored_routes = state.store.list_routes().await.unwrap();
|
||||
assert!(
|
||||
!stored_routes
|
||||
.iter()
|
||||
.any(|r| r.destination.trunc().to_string() == "10.100.2.0/24"),
|
||||
"peer-allocation Network CIDR must not be persisted as a static route"
|
||||
);
|
||||
|
||||
let ruleset = net_engine.get_active_nftables_ruleset().await.unwrap();
|
||||
assert!(
|
||||
ruleset.contains("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade"),
|
||||
"Interface-CIDR peers must keep existing NAT: {ruleset}"
|
||||
);
|
||||
assert!(
|
||||
ruleset.contains("ip saddr 10.100.2.0/24 oifname != \"wg*\" masquerade"),
|
||||
"selected Network CIDR must be masqueraded for full-tunnel Internet: {ruleset}"
|
||||
);
|
||||
|
||||
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap();
|
||||
assert!(
|
||||
live_stats
|
||||
.peers
|
||||
.iter()
|
||||
.any(|p| p.allowed_ips.iter().any(|a| a == "10.100.2.1/32")),
|
||||
"kernel peer AllowedIPs must include the selected-Network assignment"
|
||||
);
|
||||
|
||||
let (fallback_priv, fallback_pub) = generate_keypair();
|
||||
let fallback_peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface.id,
|
||||
name: "fallback-null-network".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: fallback_pub,
|
||||
private_key: Some(fallback_priv),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.100.0.2/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.store.create_peer(&fallback_peer).await.unwrap();
|
||||
assert_eq!(
|
||||
fallback_peer.server_wireguard_allowed_ips(),
|
||||
"10.100.0.2/32"
|
||||
);
|
||||
|
||||
let report = reconciler.apply().await.unwrap();
|
||||
assert!(report.success);
|
||||
let ruleset = net_engine.get_active_nftables_ruleset().await.unwrap();
|
||||
assert!(ruleset.contains("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade"));
|
||||
assert!(ruleset.contains("ip saddr 10.100.2.0/24 oifname != \"wg*\" masquerade"));
|
||||
|
||||
let plan = reconciler.plan().await.unwrap();
|
||||
assert!(!plan.has_drift);
|
||||
assert_eq!(plan.firewall_changes, 0);
|
||||
assert_eq!(plan.route_changes, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interface_editing_persistence_and_key_preservation() {
|
||||
let (state, iface, _peer, session_id) = setup_test_context().await;
|
||||
@@ -378,7 +526,7 @@ async fn test_interface_editing_persistence_and_key_preservation() {
|
||||
// 2. Query updated interface from database
|
||||
let updated_iface = state.store.get_interface(orig_id).await.unwrap().unwrap();
|
||||
assert_eq!(updated_iface.address_v4.to_string(), "10.200.0.1/24");
|
||||
assert_eq!(updated_iface.listen_port, 51822);
|
||||
assert_eq!(updated_iface.listen_port, Some(51822));
|
||||
assert_eq!(updated_iface.mtu, Some(1360));
|
||||
assert_eq!(updated_iface.dns, Some("9.9.9.9".to_string()));
|
||||
|
||||
@@ -491,6 +639,139 @@ async fn test_server_endpoint_persistence_validation_and_export_precedence() {
|
||||
assert!(conf_str.contains("Endpoint = vpn.wan-domain.org:51820"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_structured_server_endpoint_settings_api_and_peer_export() {
|
||||
let (state, _iface, peer, session_id) = setup_test_context().await;
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// 1. Invalid wireguard.server_host with embedded port is rejected with 422
|
||||
let invalid_host_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_host",
|
||||
"value": "vpn.thakares.com:51820", // embedded port!
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(invalid_host_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// 2. Invalid wireguard.server_port (0) is rejected with 422
|
||||
let invalid_port_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_port",
|
||||
"value": "0",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(invalid_port_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// 3. Valid wireguard.server_host and wireguard.server_port save successfully
|
||||
let set_host_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_host",
|
||||
"value": "vpn.thakares.com",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(set_host_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let set_port_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_port",
|
||||
"value": "51820",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(set_port_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 4. Export config automatically resolves Endpoint = vpn.thakares.com:51820
|
||||
let export_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(export_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
|
||||
assert!(conf_str.contains("Endpoint = vpn.thakares.com:51820"));
|
||||
|
||||
// 5. Export QR returns valid SVG
|
||||
let qr_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/qr", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(qr_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 6. Set IPv6 host -> exports [2001:db8::10]:51820
|
||||
let set_v6_req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/v1/system/settings")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"key": "wireguard.server_host",
|
||||
"value": "2001:db8::10",
|
||||
"is_secret": false
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(set_v6_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let export_v6_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(export_v6_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
|
||||
assert!(conf_str.contains("Endpoint = [2001:db8::10]:51820"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
||||
let (state_orig, iface, peer, session_id) = setup_test_context().await;
|
||||
@@ -518,7 +799,7 @@ async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
||||
let live_iface = LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
listen_port: iface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: vec![live_peer],
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
|
||||
@@ -123,6 +123,23 @@ async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
||||
assert!(html.contains("triggerCreateBackup"));
|
||||
assert!(html.contains("openClientExportModal"));
|
||||
assert!(html.contains("openAddPeerModal"));
|
||||
|
||||
// CLI console global preview and handler exposure
|
||||
assert!(html.contains("window.updateCliCommandPreview"));
|
||||
assert!(html.contains("window.onCliCommandChange"));
|
||||
assert!(html.contains("window.onCliSubcommandChange"));
|
||||
assert!(html.contains("window.onCliSubSubcommandChange"));
|
||||
assert!(html.contains("window.executeCliConsoleCommand"));
|
||||
|
||||
// Peer enrollment must submit the selected Network UUID as network_id,
|
||||
// never the display name or CIDR.
|
||||
assert!(html.contains(r#"value="${n.id}""#));
|
||||
assert!(html.contains("${escapeHtml(n.name)} (${n.cidr})"));
|
||||
assert!(html.contains("network_id: networkId"));
|
||||
assert!(html.contains("isNetworkUuid"));
|
||||
assert!(html.contains("selectedNetwork.id"));
|
||||
assert!(!html.contains("network: network || null"));
|
||||
assert!(!html.contains(r#"value="${n.name}""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
//! Comprehensive Integration and Lifecycle Test Suite for NX9-WG Optional Upstream interfaces.
|
||||
//!
|
||||
//! Verifies:
|
||||
//! - ProtonVPN-style .conf import, parsing, validation, persistence, and kernel synchronization
|
||||
//! - wg0 overlay non-regression during all upstream operations
|
||||
//! - Upstream enable, disable, restart, and deletion lifecycles
|
||||
//! - Reconciliation engine drift detection, convergence, and orphan cleanup
|
||||
//! - Zero secret leakage across API preview, import, status, list, and CLI
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
|
||||
use nx9_wg_api::collect_managed_wg_subnets;
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::routes::cli::{ExecuteCliRequest, build_safe_argv, scrub_secrets};
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wireguard::{LiveInterfaceStats, SimulatedWireGuardEngine, WireGuardEngine};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TestHarness {
|
||||
_dir: TempDir,
|
||||
store: Store,
|
||||
_state: AppState,
|
||||
wg_engine: Arc<SimulatedWireGuardEngine>,
|
||||
_net_engine: Arc<SimulatedNetworkEngine>,
|
||||
reconciler: Arc<ReconciliationEngine>,
|
||||
app: axum::Router,
|
||||
session_cookie: String,
|
||||
}
|
||||
|
||||
async fn setup_test_harness() -> TestHarness {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let db_path = dir.path().join("upstream_test.db");
|
||||
let store = Store::connect(&db_path.to_string_lossy())
|
||||
.await
|
||||
.expect("connect to db");
|
||||
store.migrate().await.expect("run migrations");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("AdminSecret123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap admin");
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
let state = AppState::with_engines(store.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let reconciler = Arc::new(ReconciliationEngine::new(
|
||||
state.clone(),
|
||||
wg_engine.clone(),
|
||||
net_engine.clone(),
|
||||
));
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Login to get session ID
|
||||
let login_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "AdminSecret123!"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(login_req).await.expect("login request");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let cookie_header = resp
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.expect("set-cookie")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let (wg0_priv, wg0_pub) = generate_keypair();
|
||||
let wg0 = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: wg0_priv,
|
||||
public_key: wg0_pub.clone(),
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store.create_interface(&wg0).await.unwrap();
|
||||
|
||||
let (client_priv, client_pub) = generate_keypair();
|
||||
let client_peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: wg0.id,
|
||||
name: "client-alice".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: client_pub,
|
||||
private_key: Some(client_priv),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.100.0.2/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(validate_cidr("10.100.0.2/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store.create_peer(&client_peer).await.unwrap();
|
||||
|
||||
// Baseline reconciliation to converge initial network/firewall/wg state
|
||||
reconciler.apply().await.unwrap();
|
||||
|
||||
TestHarness {
|
||||
_dir: dir,
|
||||
store,
|
||||
_state: state,
|
||||
wg_engine,
|
||||
_net_engine: net_engine,
|
||||
reconciler,
|
||||
app,
|
||||
session_cookie,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_proton_conf(priv_k_str: &str, provider_pub_k_str: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
# ProtonVPN WireGuard Configuration
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
PersistentKeepalive = 25
|
||||
"#,
|
||||
priv_k_str, provider_pub_k_str
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proton0_import_and_kernel_sync() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// 1. Preview API endpoint (read-only, no side effects)
|
||||
let preview_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/preview")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let preview_resp = harness.app.clone().oneshot(preview_req).await.unwrap();
|
||||
assert_eq!(preview_resp.status(), StatusCode::OK);
|
||||
let preview_body: Value = serde_json::from_slice(
|
||||
&to_bytes(preview_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(preview_body["name"], "proton0");
|
||||
assert_eq!(preview_body["role"], "upstream");
|
||||
assert_eq!(preview_body["address_v4"], "10.2.0.2/32");
|
||||
assert_eq!(preview_body["dns"], "10.2.0.1");
|
||||
assert_eq!(preview_body["provider_public_key"], provider_pub_k.as_str());
|
||||
assert_eq!(preview_body["provider_endpoint"], "37.19.199.155:51820");
|
||||
assert_eq!(preview_body["provider_allowed_ips"], "0.0.0.0/0, ::/0");
|
||||
assert_eq!(preview_body["persistent_keepalive"], 25);
|
||||
// Ensure secrets are never in response
|
||||
assert!(preview_body.get("private_key").is_none());
|
||||
assert!(preview_body.get("preshared_key").is_none());
|
||||
|
||||
// Verify DB still only has wg0 (preview didn't write to DB)
|
||||
assert_eq!(harness.store.list_interfaces().await.unwrap().len(), 1);
|
||||
|
||||
// 2. Import API endpoint (transactional persistence + kernel sync)
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(import_resp.status(), StatusCode::OK);
|
||||
let import_body: Value =
|
||||
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
|
||||
let iface_id = import_body["interface_id"].as_str().unwrap();
|
||||
let peer_id = import_body["peer_id"].as_str().unwrap();
|
||||
assert_eq!(import_body["name"], "proton0");
|
||||
assert_eq!(import_body["role"], "upstream");
|
||||
assert!(import_body.get("private_key").is_none());
|
||||
assert!(import_body.get("preshared_key").is_none());
|
||||
|
||||
// 3. Verify SQLite desired state
|
||||
let iface = harness
|
||||
.store
|
||||
.get_interface(Uuid::parse_str(iface_id).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("proton0 in db");
|
||||
assert_eq!(iface.name, "proton0");
|
||||
assert_eq!(iface.role, InterfaceRole::Upstream);
|
||||
assert_eq!(iface.public_key.as_str(), pub_k.as_str());
|
||||
|
||||
let peers = harness
|
||||
.store
|
||||
.list_peers_for_interface(iface.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].id.to_string(), peer_id);
|
||||
assert_eq!(peers[0].public_key.as_str(), provider_pub_k.as_str());
|
||||
assert_eq!(peers[0].allowed_ips, "0.0.0.0/0, ::/0");
|
||||
|
||||
// 4. Verify Kernel Simulation state
|
||||
let kernel_stats = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("proton0 in kernel");
|
||||
assert_eq!(kernel_stats.name, "proton0");
|
||||
assert!(kernel_stats.is_up);
|
||||
assert_eq!(kernel_stats.peers.len(), 1);
|
||||
assert_eq!(kernel_stats.peers[0].public_key, provider_pub_k.as_str());
|
||||
assert_eq!(
|
||||
kernel_stats.peers[0].endpoint,
|
||||
Some("37.19.199.155:51820".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
kernel_stats.peers[0].allowed_ips,
|
||||
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
|
||||
);
|
||||
assert_eq!(kernel_stats.peers[0].persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wg0_non_regression_during_upstream_operations() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// Import proton0
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 1. wg0 remains Overlay
|
||||
let wg0 = harness
|
||||
.store
|
||||
.get_interface_by_name("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("wg0 exists");
|
||||
assert_eq!(wg0.role, InterfaceRole::Overlay);
|
||||
assert_eq!(wg0.address_v4.to_string(), "10.100.0.1/24");
|
||||
|
||||
// 2. wg0 peers unchanged and RoadWarrior AllowedIPs remain strictly /32
|
||||
let wg0_peers = harness
|
||||
.store
|
||||
.list_peers_for_interface(wg0.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wg0_peers.len(), 1);
|
||||
assert_eq!(wg0_peers[0].name, "client-alice");
|
||||
assert_eq!(
|
||||
wg0_peers[0].server_wireguard_allowed_ips_for_role(InterfaceRole::Overlay),
|
||||
"10.100.0.2/32"
|
||||
);
|
||||
|
||||
// 3. Managed subnets for client NAT masquerade only includes Overlay interfaces
|
||||
let subnets = collect_managed_wg_subnets(&harness.store).await.unwrap();
|
||||
assert_eq!(subnets.len(), 1);
|
||||
assert_eq!(subnets[0].to_string(), "10.100.0.1/24");
|
||||
// proton0 address (10.2.0.2/32) is NOT in client NAT subnets!
|
||||
assert!(!subnets.iter().any(|s| s.to_string().contains("10.2.0.2")));
|
||||
|
||||
// 4. Reconciliation plan reports zero drift
|
||||
let plan = harness.reconciler.plan().await.unwrap();
|
||||
assert!(!plan.has_drift, "Plan must be clean and fully converged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_restart_lifecycle() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// Import proton0
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
let import_body: Value =
|
||||
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
let iface_id = import_body["interface_id"].as_str().unwrap();
|
||||
|
||||
// Restart proton0
|
||||
let restart_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}/restart"))
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let restart_resp = harness.app.clone().oneshot(restart_req).await.unwrap();
|
||||
assert_eq!(restart_resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify same interface ID in DB
|
||||
let iface_after = harness
|
||||
.store
|
||||
.get_interface(Uuid::parse_str(iface_id).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("iface exists");
|
||||
assert_eq!(iface_after.name, "proton0");
|
||||
assert_eq!(iface_after.role, InterfaceRole::Upstream);
|
||||
|
||||
// Verify provider peer restored in kernel
|
||||
let kernel_stats = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("proton0 live");
|
||||
assert_eq!(kernel_stats.peers.len(), 1);
|
||||
assert_eq!(kernel_stats.peers[0].public_key, provider_pub_k.as_str());
|
||||
assert_eq!(
|
||||
kernel_stats.peers[0].allowed_ips,
|
||||
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_delete_lifecycle() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// Import proton0
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
let import_body: Value =
|
||||
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
let iface_id = import_body["interface_id"].as_str().unwrap();
|
||||
|
||||
// Verify present in kernel before delete
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// Delete proton0
|
||||
let del_req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let del_resp = harness.app.clone().oneshot(del_req).await.unwrap();
|
||||
assert_eq!(del_resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify absent from kernel
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Verify absent from DB
|
||||
assert!(
|
||||
harness
|
||||
.store
|
||||
.get_interface(Uuid::parse_str(iface_id).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Verify wg0 remains untouched
|
||||
assert!(
|
||||
harness
|
||||
.store
|
||||
.get_interface_by_name("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_reconciliation_orphan_detection() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
// Inject an orphan upstream interface into simulated kernel
|
||||
harness
|
||||
.wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "orphan_vpn0".to_string(),
|
||||
public_key: "orphanpubkey12345".to_string(),
|
||||
listen_port: 51830,
|
||||
fwmark: 0,
|
||||
peers: vec![],
|
||||
addresses: vec!["10.99.0.1/24".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Detect orphan in plan
|
||||
let plan = harness.reconciler.plan().await.unwrap();
|
||||
assert!(plan.has_drift);
|
||||
let orphan_action = plan
|
||||
.actions
|
||||
.iter()
|
||||
.find(|a| a.resource_id == "orphan_vpn0")
|
||||
.expect("orphan action in plan");
|
||||
assert_eq!(orphan_action.action_type, "delete_orphan_interface");
|
||||
|
||||
// Apply cleanup
|
||||
let report = harness.reconciler.apply().await.unwrap();
|
||||
assert!(
|
||||
report
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.contains("Removed orphan kernel interface 'orphan_vpn0'"))
|
||||
);
|
||||
|
||||
// Verify orphan was deleted from kernel
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("orphan_vpn0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Verify wg0 remains active
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_secret_safety() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let raw_priv = priv_k.as_str().to_string();
|
||||
let conf = sample_proton_conf(&raw_priv, provider_pub_k.as_str());
|
||||
|
||||
// 1. Preview response secret check
|
||||
let preview_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/preview")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let preview_resp = harness.app.clone().oneshot(preview_req).await.unwrap();
|
||||
let preview_text = String::from_utf8(
|
||||
to_bytes(preview_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!preview_text.contains(&raw_priv),
|
||||
"PrivateKey leaked in preview response"
|
||||
);
|
||||
|
||||
// 2. Import response secret check
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
let import_text = String::from_utf8(
|
||||
to_bytes(import_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!import_text.contains(&raw_priv),
|
||||
"PrivateKey leaked in import response"
|
||||
);
|
||||
|
||||
// 3. Read-only CLI output secret scrubber check
|
||||
let scrubbed = scrub_secrets(&format!(
|
||||
"private_key: {}\nPrivateKey = {}",
|
||||
raw_priv, raw_priv
|
||||
));
|
||||
assert!(
|
||||
!scrubbed.contains(&raw_priv),
|
||||
"PrivateKey leaked past scrubber"
|
||||
);
|
||||
|
||||
// 4. Safe argv builder allows read-only Upstream queries
|
||||
let list_req = ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("upstream".to_string()),
|
||||
sub_subcommand: Some("list".to_string()),
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
let argv = build_safe_argv(&list_req).unwrap();
|
||||
assert_eq!(argv, vec!["interface", "upstream", "list"]);
|
||||
|
||||
// 5. Prohibited mutating commands rejected by CLI allowlist
|
||||
let import_cli_req = ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("upstream".to_string()),
|
||||
sub_subcommand: Some("import".to_string()),
|
||||
target: Some("proton0".to_string()),
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
assert!(build_safe_argv(&import_cli_req).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_without_listen_port_does_not_conflict_with_wg0() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
// 1. Verify wg0 already owns local UDP 51820
|
||||
let wg0_initial = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wg0_initial.listen_port, 51820);
|
||||
|
||||
// 2. Import proton0 from a configuration with no ListenPort
|
||||
let (proton_priv, _) = generate_keypair();
|
||||
let (_, provider_pub) = generate_keypair();
|
||||
let conf = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
PersistentKeepalive = 25
|
||||
"#,
|
||||
proton_priv.as_str(),
|
||||
provider_pub.as_str()
|
||||
);
|
||||
|
||||
let import_req = Request::builder()
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.method("POST")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let import_res: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(import_res["name"], "proton0");
|
||||
assert_eq!(import_res["role"], "upstream");
|
||||
assert_eq!(import_res["listen_port"], Value::Null);
|
||||
assert_eq!(import_res["provider_endpoint"], "37.19.199.155:51820");
|
||||
assert_eq!(import_res["provider_allowed_ips"], "0.0.0.0/0, ::/0");
|
||||
|
||||
// 3. Verify wg0 remains on UDP 51820 and unchanged
|
||||
let wg0_db = harness
|
||||
.store
|
||||
.get_interface_by_name("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wg0_db.listen_port, Some(51820));
|
||||
assert_eq!(wg0_db.role, InterfaceRole::Overlay);
|
||||
|
||||
// 4. Verify proton0 desired state in DB has listen_port = None
|
||||
let proton_db = harness
|
||||
.store
|
||||
.get_interface_by_name("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(proton_db.listen_port, None);
|
||||
assert_eq!(proton_db.role, InterfaceRole::Upstream);
|
||||
|
||||
// 5. Verify simulated kernel state has both wg0 (51820) and proton0 (dynamic/0)
|
||||
let live_wg0 = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(live_wg0.listen_port, 51820);
|
||||
|
||||
let live_proton = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(live_proton.listen_port, 0);
|
||||
assert_eq!(live_proton.peers.len(), 1);
|
||||
assert_eq!(
|
||||
live_proton.peers[0].allowed_ips,
|
||||
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_explicit_upstream_listen_port_is_preserved() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
let (proton_priv, _) = generate_keypair();
|
||||
let (_, provider_pub) = generate_keypair();
|
||||
let conf = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
ListenPort = 45000
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
"#,
|
||||
proton_priv.as_str(),
|
||||
provider_pub.as_str()
|
||||
);
|
||||
|
||||
let import_req = Request::builder()
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.method("POST")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "custom_vpn0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let import_res: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(import_res["listen_port"], 45000);
|
||||
|
||||
let iface_db = harness
|
||||
.store
|
||||
.get_interface_by_name("custom_vpn0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(iface_db.listen_port, Some(45000));
|
||||
|
||||
let live_custom = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("custom_vpn0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(live_custom.listen_port, 45000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_missing_listen_port_no_false_drift() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
// 1. Create upstream interface proton0 in DB with listen_port = None
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let (_, peer_pub) = generate_keypair();
|
||||
let iface_id = Uuid::new_v4();
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "proton0".to_string(),
|
||||
role: InterfaceRole::Upstream,
|
||||
private_key: priv_k,
|
||||
public_key: pub_k.clone(),
|
||||
listen_port: None,
|
||||
address_v4: validate_cidr("10.2.0.2/32").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: None,
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
harness.store.create_interface(&iface).await.unwrap();
|
||||
|
||||
let peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface_id,
|
||||
name: "proton0-provider".to_string(),
|
||||
peer_type: PeerType::Server,
|
||||
state: PeerState::Active,
|
||||
public_key: peer_pub.clone(),
|
||||
private_key: None,
|
||||
preshared_key: None,
|
||||
endpoint: Some("37.19.199.155:51820".to_string()),
|
||||
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||
server_allowed_ips: Some("0.0.0.0/0, ::/0".to_string()),
|
||||
address_v4: None,
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: Some(1420),
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::Custom,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
harness.store.create_peer(&peer).await.unwrap();
|
||||
|
||||
// 2. Inject live kernel stats where the kernel has allocated an ephemeral dynamic port 54321
|
||||
harness
|
||||
.wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "proton0".to_string(),
|
||||
public_key: pub_k.as_str().to_string(),
|
||||
listen_port: 54321, // dynamic kernel-allocated port
|
||||
fwmark: 0,
|
||||
peers: vec![nx9_wireguard::LivePeerStats {
|
||||
public_key: peer_pub.as_str().to_string(),
|
||||
endpoint: Some("37.19.199.155:51820".to_string()),
|
||||
rx_bytes: 100,
|
||||
tx_bytes: 200,
|
||||
last_handshake_at: None,
|
||||
allowed_ips: vec!["0.0.0.0/0".to_string(), "::/0".to_string()],
|
||||
persistent_keepalive: Some(25),
|
||||
}],
|
||||
addresses: vec!["10.2.0.2/32".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
// 3. Run reconciliation plan — must NOT flag drift for the dynamic listen port
|
||||
let plan = harness.reconciler.plan().await.unwrap();
|
||||
assert!(
|
||||
!plan.has_drift,
|
||||
"Expected zero drift for dynamic kernel listen port when desired listen_port is None, but got: {:?}",
|
||||
plan.actions
|
||||
);
|
||||
assert_eq!(plan.interface_changes, 0);
|
||||
assert_eq!(plan.peer_changes, 0);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ use nx9_wg_core::types::firewall::{
|
||||
};
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey, WireGuardPublicKey,
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey,
|
||||
WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
@@ -61,13 +62,14 @@ async fn test_automatic_ip_allocation() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg50".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51850,
|
||||
listen_port: Some(51850),
|
||||
address_v4: "10.50.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -152,13 +154,14 @@ async fn test_peer_expiration_lifecycle() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg60".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51860,
|
||||
listen_port: Some(51860),
|
||||
address_v4: "10.60.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -288,13 +291,14 @@ async fn test_peer_firewall_and_port_ranges() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg70".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51870,
|
||||
listen_port: Some(51870),
|
||||
address_v4: "10.70.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -43,6 +43,26 @@ pub fn generate_keypair() -> (WireGuardPrivateKey, WireGuardPublicKey) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Derive a WireGuard public key (x25519) from a base64-encoded private key.
|
||||
pub fn derive_public_key(private_key_b64: &str) -> Result<WireGuardPublicKey> {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
let key_bytes = STANDARD
|
||||
.decode(private_key_b64.trim())
|
||||
.map_err(|e| Nx9Error::Validation(format!("invalid base64 private key: {e}")))?;
|
||||
if key_bytes.len() != 32 {
|
||||
return Err(Nx9Error::Validation(
|
||||
"private key must be exactly 32 bytes (256 bits)".to_string(),
|
||||
));
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&key_bytes);
|
||||
let secret = StaticSecret::from(bytes);
|
||||
let public = PublicKey::from(&secret);
|
||||
Ok(WireGuardPublicKey::new(STANDARD.encode(public.as_bytes())))
|
||||
}
|
||||
|
||||
/// Generate a WireGuard preshared key (32 random bytes, base64).
|
||||
pub fn generate_preshared_key() -> WireGuardPresharedKey {
|
||||
use base64::Engine;
|
||||
@@ -106,6 +126,15 @@ mod tests {
|
||||
let (priv_key, pub_key) = generate_keypair();
|
||||
assert!(!priv_key.as_str().is_empty());
|
||||
assert!(!pub_key.as_str().is_empty());
|
||||
|
||||
let derived_pub = derive_public_key(priv_key.as_str()).unwrap();
|
||||
assert_eq!(derived_pub.as_str(), pub_key.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_public_key_invalid() {
|
||||
assert!(derive_public_key("not-base64!").is_err());
|
||||
assert!(derive_public_key("dG9vLXNob3J0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,3 +29,27 @@ impl std::fmt::Debug for Setting {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent WireGuard server endpoint configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerEndpointSettings {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ServerEndpointSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
port: 51820,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const SETTING_SERVER_HOST: &str = "wireguard.server_host";
|
||||
pub const SETTING_SERVER_PORT: &str = "wireguard.server_port";
|
||||
pub const SETTING_SERVER_ENDPOINT_ENABLED: &str = "wireguard.server_endpoint_enabled";
|
||||
pub const LEGACY_SETTING_SERVER_ENDPOINT: &str = "server_endpoint";
|
||||
pub const LEGACY_SETTING_PUBLIC_ENDPOINT: &str = "public_endpoint";
|
||||
@@ -170,13 +170,52 @@ impl FromStr for PeerProfile {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InterfaceRole {
|
||||
#[default]
|
||||
Overlay,
|
||||
Upstream,
|
||||
}
|
||||
|
||||
impl InterfaceRole {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Overlay => "overlay",
|
||||
Self::Upstream => "upstream",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for InterfaceRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for InterfaceRole {
|
||||
type Err = Nx9Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"overlay" => Ok(Self::Overlay),
|
||||
"upstream" => Ok(Self::Upstream),
|
||||
_ => Err(Nx9Error::Validation(format!(
|
||||
"invalid InterfaceRole: {}",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Interface {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub role: InterfaceRole,
|
||||
pub private_key: WireGuardPrivateKey,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
pub listen_port: u16,
|
||||
pub listen_port: Option<u16>,
|
||||
pub address_v4: IpNet,
|
||||
pub address_v6: Option<IpNet>,
|
||||
pub mtu: Option<u16>,
|
||||
@@ -285,6 +324,39 @@ impl Peer {
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Returns the effective server-side WireGuard AllowedIPs string for this peer,
|
||||
/// scoped by the containing interface's role.
|
||||
///
|
||||
/// For `InterfaceRole::Upstream`:
|
||||
/// Preserves the provider's configured AllowedIPs (including `0.0.0.0/0` and `::/0`)
|
||||
/// for Generic Netlink cryptokey routing on the upstream interface.
|
||||
///
|
||||
/// For `InterfaceRole::Overlay`:
|
||||
/// Delegates strictly to `server_wireguard_allowed_ips()`, guaranteeing that
|
||||
/// RoadWarrior overlay client AllowedIPs are strictly derived from assigned tunnel IPs
|
||||
/// and full-tunnel routes are never installed as server-side overlay peer AllowedIPs.
|
||||
pub fn server_wireguard_allowed_ips_for_role(&self, role: InterfaceRole) -> String {
|
||||
if role == InterfaceRole::Upstream {
|
||||
let src = self
|
||||
.server_allowed_ips
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(&self.allowed_ips);
|
||||
let mut valid = Vec::new();
|
||||
for item in src.split(',') {
|
||||
let trimmed = item.trim();
|
||||
if let Ok(net) = trimmed.parse::<IpNet>() {
|
||||
valid.push(net.to_string());
|
||||
}
|
||||
}
|
||||
if !valid.is_empty() {
|
||||
return valid.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
self.server_wireguard_allowed_ips()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -269,6 +269,120 @@ pub fn validate_client_mtu(mtu: u16) -> Result<u16> {
|
||||
Ok(mtu)
|
||||
}
|
||||
|
||||
/// Validate server host or IP for WireGuard server endpoint settings.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Trim surrounding whitespace.
|
||||
/// - Reject empty host.
|
||||
/// - Accept valid DNS hostname.
|
||||
/// - Accept valid IPv4 address.
|
||||
/// - Accept valid IPv6 address (e.g. 2001:db8::10 or [2001:db8::10]).
|
||||
/// - Reject embedded port syntax (e.g. example.com:51820, 192.168.1.1:51820, [::1]:51820)
|
||||
/// with an explicit error indicating that port belongs in the separate port field.
|
||||
pub fn validate_server_host(host: &str) -> Result<String> {
|
||||
let trimmed = host.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Nx9Error::Validation(
|
||||
"server host / IP cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if bracketed IPv6 (e.g. [2001:db8::10] or [2001:db8::10]:51820)
|
||||
if trimmed.starts_with('[') {
|
||||
if let Some(closing) = trimmed.find(']') {
|
||||
let inside = &trimmed[1..closing];
|
||||
if closing + 1 < trimmed.len() {
|
||||
// Contains characters after bracket, likely a port
|
||||
return Err(Nx9Error::Validation(
|
||||
"server host must not include a port; specify the port in the Client Endpoint Port field".into(),
|
||||
));
|
||||
}
|
||||
if inside.parse::<std::net::Ipv6Addr>().is_ok() {
|
||||
return Ok(inside.to_string());
|
||||
}
|
||||
}
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid IPv6 server host '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
|
||||
// Check if direct unbracketed IPv6
|
||||
if let Ok(ipv6) = trimmed.parse::<std::net::Ipv6Addr>() {
|
||||
return Ok(ipv6.to_string());
|
||||
}
|
||||
|
||||
// If it contains a colon and was not parsed as IPv6 above, it has an embedded port or is invalid
|
||||
if trimmed.contains(':') {
|
||||
return Err(Nx9Error::Validation(
|
||||
"server host must not include a port; specify the port in the Client Endpoint Port field".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if IPv4
|
||||
if let Ok(ipv4) = trimmed.parse::<std::net::Ipv4Addr>() {
|
||||
return Ok(ipv4.to_string());
|
||||
}
|
||||
|
||||
// Validate DNS hostname (RFC 1123 / RFC 952)
|
||||
if trimmed.len() > 253 {
|
||||
return Err(Nx9Error::Validation(
|
||||
"server hostname exceeds maximum length of 253 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
for label in trimmed.split('.') {
|
||||
if label.is_empty() {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid hostname '{trimmed}': empty label"
|
||||
)));
|
||||
}
|
||||
if label.len() > 63 {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid hostname '{trimmed}': label '{label}' exceeds 63 characters"
|
||||
)));
|
||||
}
|
||||
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid hostname '{trimmed}': contains invalid characters"
|
||||
)));
|
||||
}
|
||||
if label.starts_with('-') || label.ends_with('-') {
|
||||
return Err(Nx9Error::Validation(format!(
|
||||
"invalid hostname '{trimmed}': label '{label}' cannot start or end with hyphen"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Validate WireGuard client endpoint port (range 1..=65535).
|
||||
pub fn validate_server_port(port: u16) -> Result<u16> {
|
||||
if port == 0 {
|
||||
return Err(Nx9Error::Validation(
|
||||
"server endpoint port must be between 1 and 65535".into(),
|
||||
));
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Format host and port into a standard WireGuard Endpoint string.
|
||||
///
|
||||
/// Formats IPv6 as `[host]:port` and hostname/IPv4 as `host:port`.
|
||||
pub fn format_endpoint(host: &str, port: u16) -> String {
|
||||
let trimmed = host.trim();
|
||||
let unbracketed = trimmed
|
||||
.strip_prefix('[')
|
||||
.and_then(|s| s.strip_suffix(']'))
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
if unbracketed.parse::<std::net::Ipv6Addr>().is_ok() || unbracketed.contains(':') {
|
||||
format!("[{}]:{}", unbracketed, port)
|
||||
} else {
|
||||
format!("{}:{}", unbracketed, port)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -355,4 +469,67 @@ mod tests {
|
||||
assert!(validate_client_mtu(9001).is_err());
|
||||
assert!(validate_client_mtu(65535).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_server_host_valid() {
|
||||
assert_eq!(
|
||||
validate_server_host("vpn.thakares.com").unwrap(),
|
||||
"vpn.thakares.com"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_server_host(" 203.0.113.10 ").unwrap(),
|
||||
"203.0.113.10"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_server_host("2001:db8::10").unwrap(),
|
||||
"2001:db8::10"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_server_host("[2001:db8::10]").unwrap(),
|
||||
"2001:db8::10"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_server_host("vpn-node-01.internal").unwrap(),
|
||||
"vpn-node-01.internal"
|
||||
);
|
||||
assert_eq!(validate_server_host("localhost").unwrap(), "localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_server_host_invalid() {
|
||||
assert!(validate_server_host("").is_err());
|
||||
assert!(validate_server_host(" ").is_err());
|
||||
// Embedded ports rejected
|
||||
assert!(validate_server_host("vpn.thakares.com:51820").is_err());
|
||||
assert!(validate_server_host("203.0.113.10:51820").is_err());
|
||||
assert!(validate_server_host("[2001:db8::10]:51820").is_err());
|
||||
// Invalid hostname characters
|
||||
assert!(validate_server_host("vpn$host.com").is_err());
|
||||
assert!(validate_server_host("-invalid.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_server_port() {
|
||||
assert_eq!(validate_server_port(1).unwrap(), 1);
|
||||
assert_eq!(validate_server_port(51820).unwrap(), 51820);
|
||||
assert_eq!(validate_server_port(65535).unwrap(), 65535);
|
||||
assert!(validate_server_port(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_endpoint() {
|
||||
assert_eq!(
|
||||
format_endpoint("vpn.thakares.com", 51820),
|
||||
"vpn.thakares.com:51820"
|
||||
);
|
||||
assert_eq!(format_endpoint("203.0.113.10", 51820), "203.0.113.10:51820");
|
||||
assert_eq!(
|
||||
format_endpoint("2001:db8::10", 51820),
|
||||
"[2001:db8::10]:51820"
|
||||
);
|
||||
assert_eq!(
|
||||
format_endpoint("[2001:db8::10]", 51820),
|
||||
"[2001:db8::10]:51820"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
# nx9-db — SQLite Persistence Layer
|
||||
# nx9-wg-db — SQLite Persistence Layer
|
||||
|
||||
`nx9-db` provides the authoritative SQLite persistence layer for the `nx9-wg` native Rust WireGuard management system.
|
||||
`nx9-wg-db` provides the authoritative SQLite persistence layer for the `nx9-wg` native Rust WireGuard management system.
|
||||
|
||||
## Architectural Boundaries
|
||||
|
||||
- **Authoritative State**: SQLite is the authoritative persistent store for `nx9-wg` desired state. It stores what the system intends the network, interfaces, peers, routes, firewall rules, administrator credentials, sessions, tokens, and settings to be.
|
||||
- **Separation of Concerns**: SQLite records desired configuration only. Live kernel state (WireGuard interface status, handshake counters, packet counters, live nftables rules, live kernel routes) is queried directly from Linux kernel subsystems in later phases.
|
||||
- **SQL Encapsulation**: All SQL queries, SQLite connection lifecycle, migrations, and row conversions are strictly encapsulated inside `nx9-db`. Neither `nx9-core`, `nx9-api`, `nx9-ui`, `nx9-wireguard`, nor `nx9-network` issue SQL directly.
|
||||
- **Authoritative State**: SQLite is the authoritative persistent store for `nx9-wg` desired state. It stores what the system intends the network, interfaces, peers, routes, firewall rules, administrator credentials, sessions, tokens, client profiles, and settings to be.
|
||||
- **Separation of Concerns**: SQLite records desired configuration only. Live kernel state (WireGuard interface status, handshake counters, packet counters, live nftables rules, live kernel routes) is queried directly from Linux kernel subsystems.
|
||||
- **SQL Encapsulation**: All SQL queries, SQLite connection lifecycle, migrations, and row conversions are strictly encapsulated inside `nx9-wg-db`. Neither `nx9-wg-core`, `nx9-wg-api`, `nx9-wg-ui`, `nx9-wireguard`, nor `nx9-wg-network` issue SQL directly.
|
||||
|
||||
## SQLite Configuration
|
||||
|
||||
@@ -16,31 +16,37 @@ Every connection opened by `Store` enforces:
|
||||
- `PRAGMA busy_timeout = 5000` — 5-second busy timeout to avoid contention errors.
|
||||
- `PRAGMA synchronous = NORMAL` — Optimal reliability and performance in WAL mode.
|
||||
|
||||
## Database Schema (12 Tables)
|
||||
## Database Schema (13 Tables)
|
||||
|
||||
1. `admin` — Single administrator identity (`CHECK (id = 1)`), Argon2id password hash, TOTP secrets, and login timestamp.
|
||||
2. `sessions` — Admin web sessions (`ON DELETE CASCADE`).
|
||||
3. `login_attempts` — IP-based login attempt tracking for brute-force rate limiting.
|
||||
4. `api_tokens` — Hashed API tokens for automation (`ON DELETE CASCADE`).
|
||||
5. `interfaces` — Desired WireGuard interfaces (`wg0`, `wg1`, etc.), private/public keys, listen port, IPv4/IPv6 CIDRs, MTU, DNS.
|
||||
5. `interfaces` — Desired WireGuard interfaces (`wg0`, `proton0`, etc.), role (`overlay`, `upstream`), private/public keys, optional listen port (`NULL` for dynamic kernel allocation), IPv4/IPv6 CIDRs, MTU, DNS.
|
||||
6. `peers` — Desired WireGuard peer definitions, classifications (`road_warrior`, `site_gateway`, `server`, `relay`), states (`active`, `disabled`, `revoked`, `expired`), profiles (`full_tunnel`, `split_tunnel`, `custom`), public/private/preshared keys, AllowedIPs, endpoints, and persistent keepalives (`ON DELETE CASCADE`).
|
||||
7. `networks` — Named network CIDRs for routing and organization.
|
||||
8. `routes` — Desired kernel routing rules (`ON DELETE SET NULL`).
|
||||
9. `firewall_rules` — Desired firewall policy rules with priorities and directions (`in`, `out`, `forward`).
|
||||
10. `settings` — Key-value system settings with secret redaction support.
|
||||
10. `settings` — Key-value system settings with secret redaction support and structured WireGuard server endpoint keys.
|
||||
11. `audit_events` — Append-only operational audit log with event filtering and pagination.
|
||||
12. `backups` — Backup metadata and manifest checksum records.
|
||||
13. `client_profiles` — Device, connection, and MTU transport profile specifications with built-in protections.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
- Migrations are defined in `crates/nx9-db/migrations/` and embedded at compile time via `sqlx::migrate!("./migrations")`.
|
||||
- Migrations are defined in `crates/nx9-wg-db/migrations/` and embedded at compile time via `sqlx::migrate!("./migrations")`:
|
||||
- `0001_initial_schema.sql` — Initial relational schema.
|
||||
- `0002_wiregui_schema.sql` — WireGUI capabilities and profile structures.
|
||||
- `0003_server_endpoint_settings.sql` — Persistent server endpoint settings.
|
||||
- `0004_interface_roles.sql` — Interface roles (`overlay` and `upstream`).
|
||||
- `0005_optional_listen_port.sql` — Nullable `listen_port` for ephemeral kernel port selection.
|
||||
- Migrations are executed automatically via `store.migrate().await?`.
|
||||
- Migrations are tracked in the `_sqlx_migrations` table for idempotency.
|
||||
|
||||
## Usage in Code
|
||||
|
||||
```rust
|
||||
use nx9_db::Store;
|
||||
use nx9_wg_db::Store;
|
||||
use std::path::Path;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -63,5 +69,5 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Tests use isolated in-memory or temporary file SQLite instances:
|
||||
|
||||
```bash
|
||||
cargo test -p nx9-db
|
||||
cargo test -p nx9-wg-db
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 0004_interface_roles.sql
|
||||
-- Explicit WireGuard Interface Role: Overlay (primary client network) or Upstream (third-party VPN tunnel)
|
||||
|
||||
ALTER TABLE interfaces ADD COLUMN role TEXT NOT NULL DEFAULT 'overlay' CHECK (role IN ('overlay', 'upstream'));
|
||||
|
||||
UPDATE interfaces SET role = 'overlay' WHERE role IS NULL OR role = '';
|
||||
@@ -0,0 +1,34 @@
|
||||
------------------------------------------------------------------------
|
||||
-- nx9-wg SQLite Migration (0005_optional_listen_port.sql)
|
||||
-- Allow nullable listen_port for Upstream interfaces with dynamic ports
|
||||
------------------------------------------------------------------------
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE interfaces_dg_tmp (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'overlay' CHECK (role IN ('overlay', 'upstream')),
|
||||
private_key TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
listen_port INTEGER,
|
||||
ipv4_cidr TEXT NOT NULL,
|
||||
ipv6_cidr TEXT,
|
||||
mtu INTEGER,
|
||||
dns TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
pre_up TEXT,
|
||||
post_up TEXT,
|
||||
pre_down TEXT,
|
||||
post_down TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT INTO interfaces_dg_tmp (id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr, mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at)
|
||||
SELECT id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr, mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at FROM interfaces;
|
||||
|
||||
DROP TABLE interfaces;
|
||||
ALTER TABLE interfaces_dg_tmp RENAME TO interfaces;
|
||||
CREATE INDEX idx_interfaces_name ON interfaces(name);
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -4,7 +4,9 @@ use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, WireGuardPrivateKey, WireGuardPublicKey,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
@@ -13,9 +15,10 @@ use uuid::Uuid;
|
||||
fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
|
||||
let id_str: String = r.try_get("id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let role_str: Option<String> = r.try_get("role").ok();
|
||||
let private_key_str: String = r.try_get("private_key")?;
|
||||
let public_key_str: String = r.try_get("public_key")?;
|
||||
let listen_port_i64: i64 = r.try_get("listen_port")?;
|
||||
let listen_port_i64: Option<i64> = r.try_get("listen_port")?;
|
||||
let ipv4_cidr_str: String = r.try_get("ipv4_cidr")?;
|
||||
let ipv6_cidr_str: Option<String> = r.try_get("ipv6_cidr")?;
|
||||
let mtu_i64: Option<i64> = r.try_get("mtu")?;
|
||||
@@ -31,6 +34,11 @@ fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{id_str}': {e}")))?;
|
||||
|
||||
let role = match role_str.as_deref() {
|
||||
Some("upstream") => InterfaceRole::Upstream,
|
||||
_ => InterfaceRole::Overlay,
|
||||
};
|
||||
|
||||
let address_v4 = IpNet::from_str(&ipv4_cidr_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid ipv4_cidr '{ipv4_cidr_str}': {e}")))?;
|
||||
|
||||
@@ -45,9 +53,10 @@ fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
|
||||
Ok(Interface {
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
private_key: WireGuardPrivateKey::new(private_key_str),
|
||||
public_key: WireGuardPublicKey::new(public_key_str),
|
||||
listen_port: listen_port_i64 as u16,
|
||||
listen_port: listen_port_i64.map(|p| p as u16),
|
||||
address_v4,
|
||||
address_v6,
|
||||
mtu: mtu_i64.map(|m| m as u16),
|
||||
@@ -73,17 +82,18 @@ pub async fn create_interface(pool: &SqlitePool, iface: &Interface) -> Result<()
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO interfaces (
|
||||
id, name, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
|
||||
id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
|
||||
mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&iface.name)
|
||||
.bind(iface.role.as_str())
|
||||
.bind(iface.private_key.as_str())
|
||||
.bind(iface.public_key.as_str())
|
||||
.bind(iface.listen_port as i64)
|
||||
.bind(iface.listen_port.map(|p| p as i64))
|
||||
.bind(&ipv4_str)
|
||||
.bind(ipv6_str)
|
||||
.bind(iface.mtu.map(|m| m as i64))
|
||||
@@ -162,16 +172,17 @@ pub async fn update_interface(pool: &SqlitePool, iface: &Interface) -> Result<()
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE interfaces
|
||||
SET name = ?, private_key = ?, public_key = ?, listen_port = ?,
|
||||
SET name = ?, role = ?, private_key = ?, public_key = ?, listen_port = ?,
|
||||
ipv4_cidr = ?, ipv6_cidr = ?, mtu = ?, dns = ?, enabled = ?,
|
||||
pre_up = ?, post_up = ?, pre_down = ?, post_down = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&iface.name)
|
||||
.bind(iface.role.as_str())
|
||||
.bind(iface.private_key.as_str())
|
||||
.bind(iface.public_key.as_str())
|
||||
.bind(iface.listen_port as i64)
|
||||
.bind(iface.listen_port.map(|p| p as i64))
|
||||
.bind(&ipv4_str)
|
||||
.bind(ipv6_str)
|
||||
.bind(iface.mtu.map(|m| m as i64))
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::settings::Setting;
|
||||
use nx9_wg_core::types::settings::{
|
||||
LEGACY_SETTING_PUBLIC_ENDPOINT, LEGACY_SETTING_SERVER_ENDPOINT,
|
||||
SETTING_SERVER_ENDPOINT_ENABLED, SETTING_SERVER_HOST, SETTING_SERVER_PORT,
|
||||
ServerEndpointSettings, Setting,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
/// Retrieve a setting by its key.
|
||||
@@ -99,3 +103,254 @@ pub async fn list_settings(pool: &SqlitePool) -> Result<Vec<Setting>> {
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Retrieve structured server endpoint settings from the database with legacy fallback.
|
||||
pub async fn get_server_endpoint_settings(pool: &SqlitePool) -> Result<ServerEndpointSettings> {
|
||||
let host_opt = get_setting_value(pool, SETTING_SERVER_HOST).await?;
|
||||
let port_opt = get_setting_value(pool, SETTING_SERVER_PORT).await?;
|
||||
let enabled_opt = get_setting_value(pool, SETTING_SERVER_ENDPOINT_ENABLED).await?;
|
||||
|
||||
let enabled = enabled_opt
|
||||
.as_deref()
|
||||
.map(|v| {
|
||||
let t = v.trim();
|
||||
t.parse::<bool>().unwrap_or_else(|_| t == "1")
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
let port = port_opt
|
||||
.as_deref()
|
||||
.and_then(|v| v.trim().parse::<u16>().ok())
|
||||
.filter(|&p| p > 0)
|
||||
.unwrap_or(51820);
|
||||
|
||||
if let Some(host) = host_opt.filter(|h| !h.trim().is_empty()) {
|
||||
return Ok(ServerEndpointSettings {
|
||||
host: host.trim().to_string(),
|
||||
port,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy fallback: inspect server_endpoint
|
||||
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_SERVER_ENDPOINT).await? {
|
||||
let trimmed = legacy.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let (legacy_host, legacy_port) = split_host_port(trimmed, port);
|
||||
return Ok(ServerEndpointSettings {
|
||||
host: legacy_host,
|
||||
port: legacy_port,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: inspect public_endpoint
|
||||
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await? {
|
||||
let trimmed = legacy.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let (legacy_host, legacy_port) = split_host_port(trimmed, port);
|
||||
return Ok(ServerEndpointSettings {
|
||||
host: legacy_host,
|
||||
port: legacy_port,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ServerEndpointSettings {
|
||||
host: String::new(),
|
||||
port,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist structured server endpoint settings.
|
||||
pub async fn set_server_endpoint_settings(
|
||||
pool: &SqlitePool,
|
||||
settings: &ServerEndpointSettings,
|
||||
) -> Result<()> {
|
||||
let host_trimmed = settings.host.trim();
|
||||
if settings.enabled && !host_trimmed.is_empty() {
|
||||
nx9_wg_core::validation::validate_server_host(host_trimmed)?;
|
||||
nx9_wg_core::validation::validate_server_port(settings.port)?;
|
||||
}
|
||||
|
||||
set_setting(pool, SETTING_SERVER_HOST, host_trimmed, false).await?;
|
||||
set_setting(pool, SETTING_SERVER_PORT, &settings.port.to_string(), false).await?;
|
||||
set_setting(
|
||||
pool,
|
||||
SETTING_SERVER_ENDPOINT_ENABLED,
|
||||
&settings.enabled.to_string(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Synchronize legacy server_endpoint setting for backwards compatibility
|
||||
if settings.enabled && !host_trimmed.is_empty() {
|
||||
let formatted = nx9_wg_core::validation::format_endpoint(host_trimmed, settings.port);
|
||||
set_setting(pool, LEGACY_SETTING_SERVER_ENDPOINT, &formatted, false).await?;
|
||||
} else {
|
||||
delete_setting(pool, LEGACY_SETTING_SERVER_ENDPOINT).await?;
|
||||
delete_setting(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authoritative server endpoint resolver.
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. Explicit endpoint override (if non-empty)
|
||||
/// 2. Persistent `wireguard.server_*` settings (when enabled and host non-empty)
|
||||
/// 3. Legacy `server_endpoint` setting (if non-empty)
|
||||
/// 4. Legacy `public_endpoint` setting (if non-empty)
|
||||
/// 5. Actionable error explaining how to configure server endpoint or provide `--endpoint`.
|
||||
pub async fn resolve_server_endpoint(
|
||||
pool: &SqlitePool,
|
||||
explicit_override: Option<&str>,
|
||||
) -> Result<String> {
|
||||
// 1. Explicit endpoint override
|
||||
if let Some(ep) = explicit_override {
|
||||
let trimmed = ep.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return parse_and_normalize_endpoint(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
// Check enabled toggle
|
||||
let enabled_opt = get_setting_value(pool, SETTING_SERVER_ENDPOINT_ENABLED).await?;
|
||||
let enabled = enabled_opt
|
||||
.as_deref()
|
||||
.map(|v| {
|
||||
let t = v.trim();
|
||||
t.parse::<bool>().unwrap_or_else(|_| t == "1")
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if !enabled {
|
||||
return Err(DbError::Validation(
|
||||
"No reachable WireGuard server endpoint is configured. Configure WireGuard Server Endpoint in Settings or provide --endpoint.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Persistent wireguard.server_* settings
|
||||
let host_opt = get_setting_value(pool, SETTING_SERVER_HOST).await?;
|
||||
let port_opt = get_setting_value(pool, SETTING_SERVER_PORT).await?;
|
||||
let port = port_opt
|
||||
.as_deref()
|
||||
.and_then(|v| v.trim().parse::<u16>().ok())
|
||||
.filter(|&p| p > 0)
|
||||
.unwrap_or(51820);
|
||||
|
||||
if let Some(host) = host_opt.filter(|h| !h.trim().is_empty()) {
|
||||
let validated_host = nx9_wg_core::validation::validate_server_host(&host)?;
|
||||
let validated_port = nx9_wg_core::validation::validate_server_port(port)?;
|
||||
return Ok(nx9_wg_core::validation::format_endpoint(
|
||||
&validated_host,
|
||||
validated_port,
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Legacy server_endpoint fallback
|
||||
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_SERVER_ENDPOINT).await? {
|
||||
let trimmed = legacy.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return parse_and_normalize_endpoint(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Legacy public_endpoint fallback
|
||||
if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await? {
|
||||
let trimmed = legacy.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return parse_and_normalize_endpoint(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Actionable error
|
||||
Err(DbError::Validation(
|
||||
"No reachable WireGuard server endpoint is configured. Configure WireGuard Server Endpoint in Settings or provide --endpoint.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn split_host_port(s: &str, default_port: u16) -> (String, u16) {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.starts_with('[')
|
||||
&& let Some(closing) = trimmed.find(']')
|
||||
{
|
||||
let host_part = &trimmed[1..closing];
|
||||
let rest = &trimmed[closing + 1..];
|
||||
if let Some(port_str) = rest.strip_prefix(':')
|
||||
&& let Ok(port) = port_str.parse::<u16>()
|
||||
&& port > 0
|
||||
{
|
||||
return (host_part.to_string(), port);
|
||||
}
|
||||
return (host_part.to_string(), default_port);
|
||||
}
|
||||
if let Ok(ipv6) = trimmed.parse::<std::net::Ipv6Addr>() {
|
||||
return (ipv6.to_string(), default_port);
|
||||
}
|
||||
if let Some(last_colon) = trimmed.rfind(':') {
|
||||
let host_part = &trimmed[..last_colon];
|
||||
let port_part = &trimmed[last_colon + 1..];
|
||||
if let Ok(port) = port_part.parse::<u16>()
|
||||
&& port > 0
|
||||
{
|
||||
return (host_part.to_string(), port);
|
||||
}
|
||||
}
|
||||
(trimmed.to_string(), default_port)
|
||||
}
|
||||
|
||||
fn parse_and_normalize_endpoint(ep: &str) -> Result<String> {
|
||||
let trimmed = ep.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DbError::Validation("endpoint cannot be empty".into()));
|
||||
}
|
||||
|
||||
if trimmed.starts_with('[')
|
||||
&& let Some(closing) = trimmed.find(']')
|
||||
{
|
||||
let host_part = &trimmed[1..closing];
|
||||
let ipv6 = host_part.parse::<std::net::Ipv6Addr>().map_err(|e| {
|
||||
DbError::Validation(format!("invalid IPv6 in endpoint '{trimmed}': {e}"))
|
||||
})?;
|
||||
let rest = &trimmed[closing + 1..];
|
||||
let port = if let Some(port_str) = rest.strip_prefix(':') {
|
||||
port_str
|
||||
.parse::<u16>()
|
||||
.map_err(|_| DbError::Validation(format!("invalid port in endpoint '{trimmed}'")))?
|
||||
} else if rest.is_empty() {
|
||||
51820
|
||||
} else {
|
||||
return Err(DbError::Validation(format!(
|
||||
"invalid endpoint format '{trimmed}'"
|
||||
)));
|
||||
};
|
||||
if port == 0 {
|
||||
return Err(DbError::Validation("port must be non-zero".into()));
|
||||
}
|
||||
return Ok(format!("[{}]:{}", ipv6, port));
|
||||
}
|
||||
|
||||
if let Ok(ipv6) = trimmed.parse::<std::net::Ipv6Addr>() {
|
||||
return Ok(format!("[{}]:51820", ipv6));
|
||||
}
|
||||
|
||||
if let Some(last_colon) = trimmed.rfind(':') {
|
||||
let host_part = &trimmed[..last_colon];
|
||||
let port_part = &trimmed[last_colon + 1..];
|
||||
if let Ok(port) = port_part.parse::<u16>() {
|
||||
if port == 0 {
|
||||
return Err(DbError::Validation("port must be non-zero".into()));
|
||||
}
|
||||
let host = nx9_wg_core::validation::validate_server_host(host_part)?;
|
||||
return Ok(nx9_wg_core::validation::format_endpoint(&host, port));
|
||||
}
|
||||
}
|
||||
|
||||
let host = nx9_wg_core::validation::validate_server_host(trimmed)?;
|
||||
Ok(nx9_wg_core::validation::format_endpoint(&host, 51820))
|
||||
}
|
||||
@@ -524,6 +524,23 @@ impl Store {
|
||||
crate::settings::list_settings(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn get_server_endpoint_settings(
|
||||
&self,
|
||||
) -> Result<nx9_wg_core::types::settings::ServerEndpointSettings> {
|
||||
crate::settings::get_server_endpoint_settings(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn set_server_endpoint_settings(
|
||||
&self,
|
||||
settings: &nx9_wg_core::types::settings::ServerEndpointSettings,
|
||||
) -> Result<()> {
|
||||
crate::settings::set_server_endpoint_settings(&self.pool, settings).await
|
||||
}
|
||||
|
||||
pub async fn resolve_server_endpoint(&self, explicit_override: Option<&str>) -> Result<String> {
|
||||
crate::settings::resolve_server_endpoint(&self.pool, explicit_override).await
|
||||
}
|
||||
|
||||
// Audit
|
||||
pub async fn create_audit_event(
|
||||
&self,
|
||||
|
||||
@@ -7,7 +7,7 @@ use nx9_wg_core::types::firewall::{
|
||||
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||
};
|
||||
use nx9_wg_core::types::network::{Network, Route};
|
||||
use nx9_wg_core::types::wireguard::Interface;
|
||||
use nx9_wg_core::types::wireguard::{Interface, InterfaceRole};
|
||||
use nx9_wg_db::Store;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
@@ -116,9 +116,10 @@ async fn test_firewall_rule_crud_and_priority_ordering() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_k,
|
||||
public_key: pub_k,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: None,
|
||||
|
||||
@@ -225,3 +225,171 @@ async fn test_backup_metadata_crud() {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_endpoint_settings_crud_and_persistence() {
|
||||
let store = Store::connect_in_memory().await.expect("connect");
|
||||
store.migrate().await.expect("migrate");
|
||||
|
||||
// 1. Initial state defaults
|
||||
let initial = store
|
||||
.get_server_endpoint_settings()
|
||||
.await
|
||||
.expect("get initial");
|
||||
assert_eq!(initial.host, "");
|
||||
assert_eq!(initial.port, 51820);
|
||||
assert!(initial.enabled);
|
||||
|
||||
// Initial resolution with no settings returns actionable error
|
||||
let err = store.resolve_server_endpoint(None).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("No reachable WireGuard server endpoint is configured")
|
||||
);
|
||||
|
||||
// 2. Set structured server endpoint settings
|
||||
let new_settings = nx9_wg_core::types::settings::ServerEndpointSettings {
|
||||
host: "vpn.thakares.com".to_string(),
|
||||
port: 51820,
|
||||
enabled: true,
|
||||
};
|
||||
store
|
||||
.set_server_endpoint_settings(&new_settings)
|
||||
.await
|
||||
.expect("set server endpoint settings");
|
||||
|
||||
let loaded = store
|
||||
.get_server_endpoint_settings()
|
||||
.await
|
||||
.expect("get loaded settings");
|
||||
assert_eq!(loaded.host, "vpn.thakares.com");
|
||||
assert_eq!(loaded.port, 51820);
|
||||
assert!(loaded.enabled);
|
||||
|
||||
// 3. Resolve persistent setting
|
||||
let resolved = store.resolve_server_endpoint(None).await.expect("resolve");
|
||||
assert_eq!(resolved, "vpn.thakares.com:51820");
|
||||
|
||||
// 4. IPv6 persistence and formatting
|
||||
let ipv6_settings = nx9_wg_core::types::settings::ServerEndpointSettings {
|
||||
host: "2001:db8::10".to_string(),
|
||||
port: 51821,
|
||||
enabled: true,
|
||||
};
|
||||
store
|
||||
.set_server_endpoint_settings(&ipv6_settings)
|
||||
.await
|
||||
.expect("set ipv6");
|
||||
let resolved_v6 = store
|
||||
.resolve_server_endpoint(None)
|
||||
.await
|
||||
.expect("resolve v6");
|
||||
assert_eq!(resolved_v6, "[2001:db8::10]:51821");
|
||||
|
||||
// 5. Disable setting
|
||||
let disabled = nx9_wg_core::types::settings::ServerEndpointSettings {
|
||||
host: "vpn.thakares.com".to_string(),
|
||||
port: 51820,
|
||||
enabled: false,
|
||||
};
|
||||
store
|
||||
.set_server_endpoint_settings(&disabled)
|
||||
.await
|
||||
.expect("set disabled");
|
||||
let err = store.resolve_server_endpoint(None).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("No reachable WireGuard server endpoint is configured")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_endpoint_resolution_precedence() {
|
||||
let store = Store::connect_in_memory().await.expect("connect");
|
||||
store.migrate().await.expect("migrate");
|
||||
|
||||
// 1. Explicit override with no settings configured
|
||||
let ep = store
|
||||
.resolve_server_endpoint(Some("custom.vpn.net:51820"))
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(ep, "custom.vpn.net:51820");
|
||||
|
||||
// 2. Configure persistent settings
|
||||
let settings = nx9_wg_core::types::settings::ServerEndpointSettings {
|
||||
host: "persistent.vpn.io".to_string(),
|
||||
port: 51820,
|
||||
enabled: true,
|
||||
};
|
||||
store
|
||||
.set_server_endpoint_settings(&settings)
|
||||
.await
|
||||
.expect("set");
|
||||
|
||||
// Precedence test: explicit override beats persistent setting
|
||||
let overridden = store
|
||||
.resolve_server_endpoint(Some("override.vpn.io:5555"))
|
||||
.await
|
||||
.expect("override beats persistent");
|
||||
assert_eq!(overridden, "override.vpn.io:5555");
|
||||
|
||||
// Precedence test: None uses persistent setting
|
||||
let default_resolved = store.resolve_server_endpoint(None).await.expect("default");
|
||||
assert_eq!(default_resolved, "persistent.vpn.io:51820");
|
||||
|
||||
// 3. Legacy fallback test when wireguard.server_host is missing
|
||||
store
|
||||
.delete_setting(nx9_wg_core::types::settings::SETTING_SERVER_HOST)
|
||||
.await
|
||||
.expect("delete new host");
|
||||
store
|
||||
.set_setting("server_endpoint", "legacy.vpn.org:51820", false)
|
||||
.await
|
||||
.expect("set legacy");
|
||||
|
||||
let legacy_resolved = store
|
||||
.resolve_server_endpoint(None)
|
||||
.await
|
||||
.expect("legacy resolved");
|
||||
assert_eq!(legacy_resolved, "legacy.vpn.org:51820");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_endpoint_survives_reopen() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let db_path = temp_dir.path().join("persist_endpoint.db");
|
||||
let db_url = format!("sqlite://{}?mode=rwc", db_path.display());
|
||||
|
||||
{
|
||||
let store = Store::connect(&db_url).await.expect("connect 1");
|
||||
store.migrate().await.expect("migrate 1");
|
||||
|
||||
let settings = nx9_wg_core::types::settings::ServerEndpointSettings {
|
||||
host: "vpn.thakares.com".to_string(),
|
||||
port: 51820,
|
||||
enabled: true,
|
||||
};
|
||||
store
|
||||
.set_server_endpoint_settings(&settings)
|
||||
.await
|
||||
.expect("set");
|
||||
}
|
||||
|
||||
// Reconnect to existing store on disk
|
||||
{
|
||||
let store = Store::connect(&db_url).await.expect("connect 2");
|
||||
let loaded = store
|
||||
.get_server_endpoint_settings()
|
||||
.await
|
||||
.expect("get after reopen");
|
||||
assert_eq!(loaded.host, "vpn.thakares.com");
|
||||
assert_eq!(loaded.port, 51820);
|
||||
assert!(loaded.enabled);
|
||||
|
||||
let resolved = store
|
||||
.resolve_server_endpoint(None)
|
||||
.await
|
||||
.expect("resolve after reopen");
|
||||
assert_eq!(resolved, "vpn.thakares.com:51820");
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use std::str::FromStr;
|
||||
@@ -22,9 +22,10 @@ async fn test_interface_and_peer_crud_and_cascade() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_k.clone(),
|
||||
public_key: pub_k.clone(),
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").expect("valid cidr"),
|
||||
address_v6: Some(IpNet::from_str("fd00::1/64").expect("valid cidr")),
|
||||
mtu: Some(1420),
|
||||
@@ -50,7 +51,8 @@ async fn test_interface_and_peer_crud_and_cascade() {
|
||||
.expect("get_interface")
|
||||
.expect("iface found");
|
||||
assert_eq!(fetched.name, "wg0");
|
||||
assert_eq!(fetched.listen_port, 51820);
|
||||
assert_eq!(fetched.role, InterfaceRole::Overlay);
|
||||
assert_eq!(fetched.listen_port, Some(51820));
|
||||
assert_eq!(fetched.address_v4.to_string(), "10.0.0.1/24");
|
||||
assert_eq!(fetched.mtu, Some(1420));
|
||||
|
||||
@@ -65,9 +67,10 @@ async fn test_interface_and_peer_crud_and_cascade() {
|
||||
let dup_iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_k.clone(),
|
||||
public_key: pub_k.clone(),
|
||||
listen_port: 51821,
|
||||
listen_port: Some(51821),
|
||||
address_v4: IpNet::from_str("10.0.1.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: None,
|
||||
@@ -233,14 +236,37 @@ async fn test_interface_and_peer_crud_and_cascade() {
|
||||
.expect("list peers");
|
||||
assert_eq!(peer_list.len(), 1);
|
||||
|
||||
// Test cascade delete: deleting interface must cascade and delete its peers
|
||||
// Test upstream interface with listen_port = None
|
||||
let upstream_id = Uuid::new_v4();
|
||||
let upstream_iface = Interface {
|
||||
id: upstream_id,
|
||||
name: "proton0".to_string(),
|
||||
role: InterfaceRole::Upstream,
|
||||
private_key: priv_k.clone(),
|
||||
public_key: pub_k.clone(),
|
||||
listen_port: None,
|
||||
address_v4: IpNet::from_str("10.2.0.2/32").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("10.2.0.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store
|
||||
.delete_interface(iface_id)
|
||||
.create_interface(&upstream_iface)
|
||||
.await
|
||||
.expect("delete interface");
|
||||
assert!(store.get_interface(iface_id).await.expect("get").is_none());
|
||||
assert!(
|
||||
store.get_peer(peer_id).await.expect("get").is_none(),
|
||||
"peer must be cascade-deleted with interface"
|
||||
);
|
||||
.expect("create upstream interface");
|
||||
let fetched_upstream = store
|
||||
.get_interface(upstream_id)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("upstream found");
|
||||
assert_eq!(fetched_upstream.name, "proton0");
|
||||
assert_eq!(fetched_upstream.role, InterfaceRole::Upstream);
|
||||
assert_eq!(fetched_upstream.listen_port, None);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub struct ClientExportState {
|
||||
pub selected_connection: ConnectionType,
|
||||
pub selected_nat: NatType,
|
||||
pub manual_mtu_override: Option<u16>,
|
||||
pub server_endpoint: Option<String>,
|
||||
pub is_default_from_settings: bool,
|
||||
pub resolved_profile: Option<ResolvedClientProfile>,
|
||||
pub is_override_enabled: bool,
|
||||
pub qr_view_active: bool,
|
||||
@@ -26,6 +28,8 @@ impl Default for ClientExportState {
|
||||
selected_connection: ConnectionType::Web,
|
||||
selected_nat: NatType::Unknown,
|
||||
manual_mtu_override: None,
|
||||
server_endpoint: None,
|
||||
is_default_from_settings: false,
|
||||
resolved_profile: None,
|
||||
is_override_enabled: false,
|
||||
qr_view_active: false,
|
||||
@@ -59,6 +63,12 @@ impl ClientExportState {
|
||||
self.selected_provider = provider;
|
||||
}
|
||||
|
||||
/// Set server endpoint override and whether it was populated from default settings.
|
||||
pub fn set_server_endpoint(&mut self, endpoint: Option<String>, is_default: bool) {
|
||||
self.server_endpoint = endpoint;
|
||||
self.is_default_from_settings = is_default;
|
||||
}
|
||||
|
||||
/// Toggle or set manual MTU override.
|
||||
pub fn set_manual_mtu(&mut self, mtu: Option<u16>) {
|
||||
self.manual_mtu_override = mtu;
|
||||
@@ -95,6 +105,14 @@ impl ClientExportState {
|
||||
if let Some(m) = self.manual_mtu_override {
|
||||
params.push(format!("mtu={m}"));
|
||||
}
|
||||
if let Some(ep) = self
|
||||
.server_endpoint
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
params.push(format!("endpoint={}", urlencoding(ep.trim())));
|
||||
}
|
||||
|
||||
if let Some(id) = profile_id {
|
||||
params.push(format!("profile={}", urlencoding(id)));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct SettingsState {
|
||||
pub default_interface: String,
|
||||
pub default_mtu: u16,
|
||||
pub default_listen_port: u16,
|
||||
pub wireguard_server_host: String,
|
||||
pub wireguard_server_port: u16,
|
||||
pub wireguard_server_endpoint_enabled: bool,
|
||||
|
||||
// Card 3: Networking
|
||||
pub nat_enabled: bool,
|
||||
@@ -46,6 +49,9 @@ impl Default for SettingsState {
|
||||
default_interface: "wg0".to_string(),
|
||||
default_mtu: 1420,
|
||||
default_listen_port: 51820,
|
||||
wireguard_server_host: String::new(),
|
||||
wireguard_server_port: 51820,
|
||||
wireguard_server_endpoint_enabled: true,
|
||||
nat_enabled: true,
|
||||
ipv4_forwarding: true,
|
||||
ipv6_forwarding: false,
|
||||
|
||||
@@ -91,7 +91,11 @@ impl ClientConfigBuilder {
|
||||
// Check if already contains port
|
||||
host_trimmed.to_string()
|
||||
} else {
|
||||
format!("{}:{}", host_trimmed, interface.listen_port)
|
||||
format!(
|
||||
"{}:{}",
|
||||
host_trimmed,
|
||||
interface.listen_port.unwrap_or(51820)
|
||||
)
|
||||
};
|
||||
lines.push(format!("Endpoint = {endpoint}"));
|
||||
|
||||
@@ -144,7 +148,7 @@ mod tests {
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||
use nx9_wg_core::types::wireguard::{PeerState, PeerType};
|
||||
use nx9_wg_core::types::wireguard::{InterfaceRole, PeerState, PeerType};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -158,9 +162,10 @@ mod tests {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub.clone(),
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -231,9 +236,10 @@ mod tests {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -36,6 +37,36 @@ pub struct LiveInterfaceStats {
|
||||
pub is_up: bool,
|
||||
}
|
||||
|
||||
/// Peer tunnel addresses that are not on the Interface connected prefix.
|
||||
///
|
||||
/// Interface-CIDR peers (e.g. 10.100.0.x with wg0 10.100.0.1/24) are already
|
||||
/// reachable via the kernel connected route created by the interface address.
|
||||
/// Selected-Network peers (e.g. 10.100.2.1/32) are not. Those prefixes must be
|
||||
/// installed as on-link device routes on the WireGuard interface so the FIB
|
||||
/// delivers packets into wg0, where cryptokey routing (AllowedIPs) applies.
|
||||
///
|
||||
/// This is not a Routes-table LAN-behind-peer destination and has no gateway.
|
||||
pub fn onlink_peer_address_prefixes(interface: &Interface, peers: &[Peer]) -> Vec<IpNet> {
|
||||
let mut prefixes = BTreeSet::new();
|
||||
for peer in peers.iter().filter(|p| p.state == PeerState::Active) {
|
||||
if let Some(v4) = peer.address_v4
|
||||
&& v4.prefix_len() > 0
|
||||
&& !interface.address_v4.contains(&v4.addr())
|
||||
{
|
||||
prefixes.insert(v4);
|
||||
}
|
||||
if let Some(v6) = peer.address_v6
|
||||
&& v6.prefix_len() > 0
|
||||
&& !interface
|
||||
.address_v6
|
||||
.is_some_and(|iface_v6| iface_v6.contains(&v6.addr()))
|
||||
{
|
||||
prefixes.insert(v6);
|
||||
}
|
||||
}
|
||||
prefixes.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Abstract WireGuard Engine interface for kernel netlink and simulated environments.
|
||||
#[async_trait::async_trait]
|
||||
pub trait WireGuardEngine: Send + Sync {
|
||||
@@ -106,7 +137,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
|
||||
.filter(|p| p.state == PeerState::Active)
|
||||
.map(|p| {
|
||||
let allowed_ips: Vec<String> = p
|
||||
.server_wireguard_allowed_ips()
|
||||
.server_wireguard_allowed_ips_for_role(interface.role)
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -132,7 +163,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
|
||||
let stats = LiveInterfaceStats {
|
||||
name: interface.name.clone(),
|
||||
public_key: interface.public_key.as_str().to_string(),
|
||||
listen_port: interface.listen_port,
|
||||
listen_port: interface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: live_peers,
|
||||
addresses,
|
||||
|
||||
@@ -6,14 +6,16 @@ pub mod error;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod native_linux;
|
||||
pub mod qr;
|
||||
pub mod upstream_parser;
|
||||
|
||||
pub use config_builder::ClientConfigBuilder;
|
||||
pub use engine::{
|
||||
LiveInterfaceStats, LivePeerStats, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine,
|
||||
WireGuardEngine,
|
||||
WireGuardEngine, onlink_peer_address_prefixes,
|
||||
};
|
||||
pub use error::{Result, WireGuardError};
|
||||
pub use qr::{
|
||||
generate_qr_ascii, generate_qr_base64, generate_qr_data_url, generate_qr_png_bytes,
|
||||
generate_qr_svg,
|
||||
};
|
||||
pub use upstream_parser::{ParsedUpstreamConfig, ParsedUpstreamPeer, UpstreamConfigParser};
|
||||
@@ -8,7 +8,9 @@
|
||||
//!
|
||||
//! No external commands (wg, ip, wg-quick, nft, sysctl) are ever executed.
|
||||
|
||||
use crate::engine::{LiveInterfaceStats, LivePeerStats, WireGuardEngine};
|
||||
use crate::engine::{
|
||||
LiveInterfaceStats, LivePeerStats, WireGuardEngine, onlink_peer_address_prefixes,
|
||||
};
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use base64::Engine as _;
|
||||
use chrono::NaiveDateTime;
|
||||
@@ -24,9 +26,13 @@ use netlink_packet_wireguard::{
|
||||
};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
||||
use rtnetlink::LinkWireguard;
|
||||
use rtnetlink::RouteMessageBuilder;
|
||||
use rtnetlink::packet_route::AddressFamily;
|
||||
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use rtnetlink::packet_route::route::{RouteAddress, RouteAttribute, RouteMessage};
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
/// Linux Native WireGuard Engine using kernel RTNETLINK and Generic Netlink.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -444,11 +450,16 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
let mut device_attrs: Vec<WireguardAttribute> = vec![
|
||||
WireguardAttribute::IfName(interface.name.clone()),
|
||||
WireguardAttribute::PrivateKey(private_key_bytes),
|
||||
WireguardAttribute::ListenPort(interface.listen_port),
|
||||
WireguardAttribute::Fwmark(0),
|
||||
WireguardAttribute::Flags(WireguardDeviceFlags::ReplacePeers),
|
||||
];
|
||||
|
||||
if let Some(port) = interface.listen_port {
|
||||
if port != 0 {
|
||||
device_attrs.push(WireguardAttribute::ListenPort(port));
|
||||
}
|
||||
}
|
||||
|
||||
// Build peer configurations for active peers only
|
||||
let mut wg_peers = Vec::new();
|
||||
for peer in peers.iter().filter(|p| p.state == PeerState::Active) {
|
||||
@@ -478,7 +489,7 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
}
|
||||
|
||||
// Server-side Allowed IPs (cryptokey routing in Linux kernel)
|
||||
let server_allowed_str = peer.server_wireguard_allowed_ips();
|
||||
let server_allowed_str = peer.server_wireguard_allowed_ips_for_role(interface.role);
|
||||
let allowed_ips = parse_allowed_ips(&server_allowed_str)?;
|
||||
if !allowed_ips.is_empty() {
|
||||
peer_attrs.push(WireguardPeerAttribute::Flags(
|
||||
@@ -852,6 +863,178 @@ fn parse_endpoint(s: &str) -> Result<SocketAddr> {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Install on-link device routes for peer tunnel addresses outside the Interface prefix.
|
||||
///
|
||||
/// Interface-CIDR peers are already covered by the connected route from the
|
||||
/// interface address. Selected-Network peer addresses are not; without a FIB
|
||||
/// path into wg0, cryptokey routing never sees the packet. Routes have no
|
||||
/// gateway and are not Routes-table LAN destinations.
|
||||
async fn ensure_onlink_peer_routes(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
let (handle, _join) = rtnetlink_handle().await?;
|
||||
|
||||
let mut links = handle
|
||||
.link()
|
||||
.get()
|
||||
.match_name(interface.name.to_string())
|
||||
.execute();
|
||||
let Some(link) = links.try_next().await.map_err(|e| {
|
||||
WireGuardError::Netlink(format!(
|
||||
"failed to resolve interface '{}' for on-link routes: {e}",
|
||||
interface.name
|
||||
))
|
||||
})?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let link_index = link.header.index;
|
||||
|
||||
let desired: HashSet<IpNet> = onlink_peer_address_prefixes(interface, peers)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let live = list_onlink_routes_for_index(&handle, link_index).await?;
|
||||
|
||||
for prefix in &desired {
|
||||
if live.contains(prefix) {
|
||||
continue;
|
||||
}
|
||||
add_onlink_device_route(&handle, link_index, *prefix).await?;
|
||||
}
|
||||
|
||||
let iface_v4 = interface.address_v4.trunc();
|
||||
let iface_v6 = interface.address_v6.map(|n| n.trunc());
|
||||
for prefix in live {
|
||||
let is_host = matches!(prefix, IpNet::V4(n) if n.prefix_len() == 32)
|
||||
|| matches!(prefix, IpNet::V6(n) if n.prefix_len() == 128);
|
||||
if !is_host {
|
||||
continue;
|
||||
}
|
||||
if prefix.trunc() == iface_v4 || iface_v6 == Some(prefix.trunc()) {
|
||||
continue;
|
||||
}
|
||||
if desired.contains(&prefix) {
|
||||
continue;
|
||||
}
|
||||
let _ = delete_onlink_device_route(&handle, link_index, prefix).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_onlink_routes_for_index(
|
||||
handle: &rtnetlink::Handle,
|
||||
link_index: u32,
|
||||
) -> Result<HashSet<IpNet>> {
|
||||
let mut results = HashSet::new();
|
||||
for family in [AddressFamily::Inet, AddressFamily::Inet6] {
|
||||
let mut req = RouteMessage::default();
|
||||
req.header.address_family = family;
|
||||
let mut stream = handle.route().get(req).execute();
|
||||
while let Some(msg) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| WireGuardError::Netlink(format!("RTNETLINK route dump failed: {e}")))?
|
||||
{
|
||||
let mut dest_ip = match family {
|
||||
AddressFamily::Inet => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
AddressFamily::Inet6 => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
_ => continue,
|
||||
};
|
||||
let mut oif = None;
|
||||
let mut has_gateway = false;
|
||||
for attr in &msg.attributes {
|
||||
match attr {
|
||||
RouteAttribute::Destination(RouteAddress::Inet(v4)) => {
|
||||
dest_ip = IpAddr::V4(*v4);
|
||||
}
|
||||
RouteAttribute::Destination(RouteAddress::Inet6(v6)) => {
|
||||
dest_ip = IpAddr::V6(*v6);
|
||||
}
|
||||
RouteAttribute::Gateway(_) => has_gateway = true,
|
||||
RouteAttribute::Oif(idx) => oif = Some(*idx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if has_gateway || oif != Some(link_index) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(net) = IpNet::new(dest_ip, msg.header.destination_prefix_length) {
|
||||
results.insert(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn add_onlink_device_route(
|
||||
handle: &rtnetlink::Handle,
|
||||
link_index: u32,
|
||||
prefix: IpNet,
|
||||
) -> Result<()> {
|
||||
let exec_result = match prefix {
|
||||
IpNet::V4(v4) => {
|
||||
let msg = RouteMessageBuilder::<Ipv4Addr>::new()
|
||||
.destination_prefix(v4.addr(), v4.prefix_len())
|
||||
.output_interface(link_index)
|
||||
.build();
|
||||
handle.route().add(msg).execute().await
|
||||
}
|
||||
IpNet::V6(v6) => {
|
||||
let msg = RouteMessageBuilder::<Ipv6Addr>::new()
|
||||
.destination_prefix(v6.addr(), v6.prefix_len())
|
||||
.output_interface(link_index)
|
||||
.build();
|
||||
handle.route().add(msg).execute().await
|
||||
}
|
||||
};
|
||||
if let Err(e) = exec_result {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("File exists") || err_str.contains("17") {
|
||||
return Ok(());
|
||||
}
|
||||
if err_str.contains("permission")
|
||||
|| err_str.contains("EPERM")
|
||||
|| err_str.contains("Operation not permitted")
|
||||
{
|
||||
return Err(WireGuardError::PermissionDenied(format!(
|
||||
"insufficient privileges to add on-link route '{prefix}': {e}"
|
||||
)));
|
||||
}
|
||||
return Err(WireGuardError::Netlink(format!(
|
||||
"failed to add on-link route '{prefix}': {e}"
|
||||
)));
|
||||
}
|
||||
tracing::info!(prefix = %prefix, "On-link peer address route added via RTNETLINK");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_onlink_device_route(
|
||||
handle: &rtnetlink::Handle,
|
||||
link_index: u32,
|
||||
prefix: IpNet,
|
||||
) -> Result<()> {
|
||||
let exec_result = match prefix {
|
||||
IpNet::V4(v4) => {
|
||||
let msg = RouteMessageBuilder::<Ipv4Addr>::new()
|
||||
.destination_prefix(v4.addr(), v4.prefix_len())
|
||||
.output_interface(link_index)
|
||||
.build();
|
||||
handle.route().del(msg).execute().await
|
||||
}
|
||||
IpNet::V6(v6) => {
|
||||
let msg = RouteMessageBuilder::<Ipv6Addr>::new()
|
||||
.destination_prefix(v6.addr(), v6.prefix_len())
|
||||
.output_interface(link_index)
|
||||
.build();
|
||||
handle.route().del(msg).execute().await
|
||||
}
|
||||
};
|
||||
if let Err(e) = exec_result {
|
||||
tracing::debug!(prefix = %prefix, error = %e, "On-link peer address route delete skipped");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── WireGuardEngine Trait Implementation ─────────────────────────────────────
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -863,6 +1046,16 @@ impl WireGuardEngine for NativeLinuxWireGuardEngine {
|
||||
// 2. Configure the WireGuard device (private key, listen port, peers)
|
||||
configure_device(interface, peers).await?;
|
||||
|
||||
// 3. On-link device routes for peer tunnel addresses outside the
|
||||
// Interface connected prefix (cryptokey routing still uses AllowedIPs).
|
||||
if let Err(e) = ensure_onlink_peer_routes(interface, peers).await {
|
||||
tracing::warn!(
|
||||
interface = %interface.name,
|
||||
error = %e,
|
||||
"On-link peer address routes skipped"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
interface = %interface.name,
|
||||
active_peers = peers.iter().filter(|p| p.state == PeerState::Active).count(),
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Third-party WireGuard configuration (.conf) parser and validator.
|
||||
//!
|
||||
//! Enforces:
|
||||
//! - Exactly one `[Interface]` section containing `PrivateKey` and `Address`.
|
||||
//! - Exactly one `[Peer]` section containing `PublicKey`, `Endpoint`, and `AllowedIPs`.
|
||||
//! - Preservation of `0.0.0.0/0`, `::/0`, and specific CIDRs.
|
||||
//! - Secret safety: Never exposes private or preshared keys in error messages.
|
||||
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::derive_public_key;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPresharedKey,
|
||||
WireGuardPrivateKey, WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_core::validation::{validate_interface_name, validate_listen_port, validate_mtu};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Parsed provider peer definition from standard WireGuard config.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedUpstreamPeer {
|
||||
pub name: String,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
pub preshared_key: Option<WireGuardPresharedKey>,
|
||||
pub endpoint: String,
|
||||
pub allowed_ips: String,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
}
|
||||
|
||||
/// Fully validated Upstream interface configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedUpstreamConfig {
|
||||
pub interface_name: String,
|
||||
pub private_key: WireGuardPrivateKey,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
pub listen_port: Option<u16>,
|
||||
pub address_v4: IpNet,
|
||||
pub address_v6: Option<IpNet>,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub peer: ParsedUpstreamPeer,
|
||||
}
|
||||
|
||||
impl ParsedUpstreamConfig {
|
||||
/// Convert parsed configuration into desired-state domain structs (`Interface`, `Peer`).
|
||||
pub fn into_desired_state(self, interface_id: Uuid, peer_id: Uuid) -> (Interface, Peer) {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface = Interface {
|
||||
id: interface_id,
|
||||
name: self.interface_name,
|
||||
role: InterfaceRole::Upstream,
|
||||
private_key: self.private_key,
|
||||
public_key: self.public_key,
|
||||
listen_port: self.listen_port,
|
||||
address_v4: self.address_v4,
|
||||
address_v6: self.address_v6,
|
||||
mtu: self.mtu,
|
||||
dns: self.dns.clone(),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let peer = Peer {
|
||||
id: peer_id,
|
||||
interface_id,
|
||||
name: self.peer.name,
|
||||
peer_type: PeerType::Server,
|
||||
state: PeerState::Active,
|
||||
public_key: self.peer.public_key,
|
||||
private_key: None,
|
||||
preshared_key: self.peer.preshared_key,
|
||||
endpoint: Some(self.peer.endpoint),
|
||||
allowed_ips: self.peer.allowed_ips.clone(),
|
||||
server_allowed_ips: Some(self.peer.allowed_ips),
|
||||
address_v4: None,
|
||||
address_v6: None,
|
||||
dns: self.dns,
|
||||
mtu: self.mtu,
|
||||
persistent_keepalive: self.peer.persistent_keepalive,
|
||||
profile: PeerProfile::Custom,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
(iface, peer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Upstream WireGuard .conf parser.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpstreamConfigParser;
|
||||
|
||||
impl UpstreamConfigParser {
|
||||
/// Parse and validate a third-party WireGuard configuration string.
|
||||
pub fn parse(raw_conf: &str, interface_name: &str) -> Result<ParsedUpstreamConfig> {
|
||||
// 1. Validate interface name
|
||||
validate_interface_name(interface_name)
|
||||
.map_err(|e| WireGuardError::Config(format!("invalid interface name: {e}")))?;
|
||||
|
||||
if interface_name == "wg0" {
|
||||
return Err(WireGuardError::Config(
|
||||
"An Upstream interface cannot use the reserved name 'wg0'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Parse sections and key-values
|
||||
let mut current_section: Option<String> = None;
|
||||
let mut interface_section_count = 0;
|
||||
let mut peer_section_count = 0;
|
||||
|
||||
let mut iface_private_key: Option<String> = None;
|
||||
let mut iface_addresses: Vec<String> = Vec::new();
|
||||
let mut iface_dns: Vec<String> = Vec::new();
|
||||
let mut iface_mtu: Option<u16> = None;
|
||||
let mut iface_listen_port: Option<u16> = None;
|
||||
|
||||
let mut peer_public_key: Option<String> = None;
|
||||
let mut peer_preshared_key: Option<String> = None;
|
||||
let mut peer_endpoint: Option<String> = None;
|
||||
let mut peer_allowed_ips: Vec<String> = Vec::new();
|
||||
let mut peer_keepalive: Option<u16> = None;
|
||||
|
||||
for (line_num, raw_line) in raw_conf.lines().enumerate() {
|
||||
let line_idx = line_num + 1;
|
||||
let line = raw_line.trim();
|
||||
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section header
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
let sec_name = line[1..line.len() - 1].trim();
|
||||
if sec_name.eq_ignore_ascii_case("interface") {
|
||||
interface_section_count += 1;
|
||||
current_section = Some("Interface".to_string());
|
||||
} else if sec_name.eq_ignore_ascii_case("peer") {
|
||||
peer_section_count += 1;
|
||||
current_section = Some("Peer".to_string());
|
||||
} else {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Unsupported section '[{sec_name}]' on line {line_idx}"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-Value pair
|
||||
let (key, val) = match line.split_once('=') {
|
||||
Some((k, v)) => (k.trim(), v.trim()),
|
||||
None => {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Invalid key-value syntax on line {line_idx}: '{line}'"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Remove trailing comments from value if any
|
||||
let clean_val = match val.split_once('#').or_else(|| val.split_once(';')) {
|
||||
Some((clean, _)) => clean.trim(),
|
||||
None => val,
|
||||
};
|
||||
|
||||
match current_section.as_deref() {
|
||||
Some("Interface") => {
|
||||
if key.eq_ignore_ascii_case("privatekey") {
|
||||
if clean_val.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"PrivateKey value cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
iface_private_key = Some(clean_val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("address") {
|
||||
for addr in clean_val.split(',') {
|
||||
let trimmed = addr.trim();
|
||||
if !trimmed.is_empty() {
|
||||
iface_addresses.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("dns") {
|
||||
for d in clean_val.split(',') {
|
||||
let trimmed = d.trim();
|
||||
if !trimmed.is_empty() {
|
||||
iface_dns.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("mtu") {
|
||||
let parsed_mtu = clean_val.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!(
|
||||
"Invalid MTU '{clean_val}' on line {line_idx}"
|
||||
))
|
||||
})?;
|
||||
validate_mtu(parsed_mtu).map_err(|e| {
|
||||
WireGuardError::Config(format!("MTU validation failed: {e}"))
|
||||
})?;
|
||||
iface_mtu = Some(parsed_mtu);
|
||||
} else if key.eq_ignore_ascii_case("listenport") {
|
||||
let parsed_port = clean_val.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!(
|
||||
"Invalid ListenPort '{clean_val}' on line {line_idx}"
|
||||
))
|
||||
})?;
|
||||
validate_listen_port(parsed_port).map_err(|e| {
|
||||
WireGuardError::Config(format!("ListenPort validation failed: {e}"))
|
||||
})?;
|
||||
iface_listen_port = Some(parsed_port);
|
||||
} else {
|
||||
tracing::debug!(key = %key, "Ignoring unrecognized Interface setting in upstream config");
|
||||
}
|
||||
}
|
||||
Some("Peer") => {
|
||||
if key.eq_ignore_ascii_case("publickey") {
|
||||
if clean_val.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"PublicKey value cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
peer_public_key = Some(clean_val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("presharedkey") {
|
||||
if !clean_val.is_empty() {
|
||||
peer_preshared_key = Some(clean_val.to_string());
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("endpoint") {
|
||||
if clean_val.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Endpoint value cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
peer_endpoint = Some(clean_val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("allowedips") {
|
||||
for item in clean_val.split(',') {
|
||||
let trimmed = item.trim();
|
||||
if !trimmed.is_empty() {
|
||||
peer_allowed_ips.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("persistentkeepalive") {
|
||||
let ka = clean_val.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!(
|
||||
"Invalid PersistentKeepalive '{clean_val}' on line {line_idx}"
|
||||
))
|
||||
})?;
|
||||
peer_keepalive = Some(ka);
|
||||
} else {
|
||||
tracing::debug!(key = %key, "Ignoring unrecognized Peer setting in upstream config");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Configuration entry '{line}' found outside any section on line {line_idx}"
|
||||
)));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Section cardinality checks
|
||||
if interface_section_count == 0 {
|
||||
return Err(WireGuardError::Config(
|
||||
"Missing [Interface] section in WireGuard configuration".to_string(),
|
||||
));
|
||||
}
|
||||
if interface_section_count > 1 {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Configuration contains {interface_section_count} [Interface] sections (expected exactly 1)"
|
||||
)));
|
||||
}
|
||||
if peer_section_count == 0 {
|
||||
return Err(WireGuardError::Config(
|
||||
"An Upstream configuration must contain exactly one [Peer] section (found 0)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if peer_section_count > 1 {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"An Upstream configuration must contain exactly one [Peer] section (found {peer_section_count})"
|
||||
)));
|
||||
}
|
||||
|
||||
// 4. Validate Interface fields
|
||||
let raw_priv_k = iface_private_key.ok_or_else(|| {
|
||||
WireGuardError::Config(
|
||||
"Missing required 'PrivateKey' in [Interface] section".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let pub_k = derive_public_key(&raw_priv_k).map_err(|_| {
|
||||
WireGuardError::Config(
|
||||
"Invalid PrivateKey: failed to decode 32-byte WireGuard key".to_string(),
|
||||
)
|
||||
})?;
|
||||
let priv_k = WireGuardPrivateKey::new(raw_priv_k);
|
||||
|
||||
if iface_addresses.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Missing required 'Address' in [Interface] section".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut v4_addr: Option<IpNet> = None;
|
||||
let mut v6_addr: Option<IpNet> = None;
|
||||
|
||||
for addr_str in &iface_addresses {
|
||||
let net = IpNet::from_str(addr_str).map_err(|e| {
|
||||
WireGuardError::Config(format!("Invalid Address '{addr_str}': {e}"))
|
||||
})?;
|
||||
match net {
|
||||
IpNet::V4(_) => {
|
||||
if v4_addr.is_none() {
|
||||
v4_addr = Some(net);
|
||||
}
|
||||
}
|
||||
IpNet::V6(_) => {
|
||||
if v6_addr.is_none() {
|
||||
v6_addr = Some(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let address_v4 = v4_addr.ok_or_else(|| {
|
||||
WireGuardError::Config(
|
||||
"Upstream configuration requires at least one IPv4 address in Address".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let dns = if iface_dns.is_empty() {
|
||||
None
|
||||
} else {
|
||||
for d in &iface_dns {
|
||||
if d.parse::<IpAddr>().is_err() {
|
||||
return Err(WireGuardError::Config(format!("Invalid DNS address '{d}'")));
|
||||
}
|
||||
}
|
||||
Some(iface_dns.join(", "))
|
||||
};
|
||||
|
||||
let listen_port = iface_listen_port;
|
||||
|
||||
// 5. Validate Peer fields
|
||||
let raw_peer_pub = peer_public_key.ok_or_else(|| {
|
||||
WireGuardError::Config("Missing required 'PublicKey' in [Peer] section".to_string())
|
||||
})?;
|
||||
|
||||
// Validate public key format (32 bytes base64)
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
let pub_bytes = STANDARD.decode(raw_peer_pub.trim()).map_err(|_| {
|
||||
WireGuardError::Config("Invalid Peer PublicKey: malformed base64".to_string())
|
||||
})?;
|
||||
if pub_bytes.len() != 32 {
|
||||
return Err(WireGuardError::Config(
|
||||
"Invalid Peer PublicKey: must be 32 bytes (256 bits)".to_string(),
|
||||
));
|
||||
}
|
||||
let peer_pub = WireGuardPublicKey::new(raw_peer_pub);
|
||||
|
||||
let preshared_k = if let Some(psk_str) = peer_preshared_key {
|
||||
let psk_bytes = STANDARD.decode(psk_str.trim()).map_err(|_| {
|
||||
WireGuardError::Config("Invalid Peer PresharedKey: malformed base64".to_string())
|
||||
})?;
|
||||
if psk_bytes.len() != 32 {
|
||||
return Err(WireGuardError::Config(
|
||||
"Invalid Peer PresharedKey: must be 32 bytes (256 bits)".to_string(),
|
||||
));
|
||||
}
|
||||
Some(WireGuardPresharedKey::new(psk_str))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let raw_endpoint = peer_endpoint.ok_or_else(|| {
|
||||
WireGuardError::Config("Missing required 'Endpoint' in [Peer] section".to_string())
|
||||
})?;
|
||||
|
||||
// Validate endpoint
|
||||
validate_endpoint_syntax(&raw_endpoint)?;
|
||||
|
||||
if peer_allowed_ips.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Missing required 'AllowedIPs' in [Peer] section".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut validated_allowed_ips = Vec::new();
|
||||
for item in &peer_allowed_ips {
|
||||
let net = IpNet::from_str(item).map_err(|e| {
|
||||
WireGuardError::Config(format!("Invalid AllowedIPs CIDR '{item}': {e}"))
|
||||
})?;
|
||||
validated_allowed_ips.push(net.to_string());
|
||||
}
|
||||
|
||||
Ok(ParsedUpstreamConfig {
|
||||
interface_name: interface_name.to_string(),
|
||||
private_key: priv_k,
|
||||
public_key: pub_k,
|
||||
listen_port,
|
||||
address_v4,
|
||||
address_v6: v6_addr,
|
||||
dns,
|
||||
mtu: iface_mtu,
|
||||
peer: ParsedUpstreamPeer {
|
||||
name: format!("{interface_name}-provider"),
|
||||
public_key: peer_pub,
|
||||
preshared_key: preshared_k,
|
||||
endpoint: raw_endpoint,
|
||||
allowed_ips: validated_allowed_ips.join(", "),
|
||||
persistent_keepalive: peer_keepalive,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate endpoint format: IP:port or hostname:port
|
||||
fn validate_endpoint_syntax(endpoint_str: &str) -> Result<()> {
|
||||
let trimmed = endpoint_str.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Endpoint cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if trimmed.parse::<SocketAddr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(idx) = trimmed.rfind(':') {
|
||||
let host = &trimmed[..idx];
|
||||
let port_str = &trimmed[idx + 1..];
|
||||
|
||||
if host.is_empty() {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Invalid endpoint '{trimmed}': missing host"
|
||||
)));
|
||||
}
|
||||
|
||||
let port = port_str.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!("Invalid endpoint port '{port_str}' in '{trimmed}'"))
|
||||
})?;
|
||||
|
||||
if port == 0 {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Invalid endpoint port 0 in '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(WireGuardError::Config(format!(
|
||||
"Invalid endpoint '{trimmed}': missing port (expected host:port)"
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Comprehensive test suite for third-party WireGuard Upstream .conf parsing and validation.
|
||||
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::InterfaceRole;
|
||||
use nx9_wireguard::UpstreamConfigParser;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_valid_proton_style_configuration() {
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
r#"
|
||||
# ProtonVPN WireGuard Configuration
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
# Server Node
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
PersistentKeepalive = 25
|
||||
"#,
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse proton config");
|
||||
assert_eq!(parsed.interface_name, "proton0");
|
||||
assert_eq!(parsed.public_key.as_str(), pub_k.as_str());
|
||||
assert_eq!(parsed.address_v4.to_string(), "10.2.0.2/32");
|
||||
assert_eq!(parsed.address_v6, None);
|
||||
assert_eq!(parsed.dns, Some("10.2.0.1".to_string()));
|
||||
assert_eq!(parsed.mtu, Some(1420));
|
||||
assert_eq!(parsed.listen_port, None);
|
||||
|
||||
assert_eq!(parsed.peer.name, "proton0-provider");
|
||||
assert_eq!(parsed.peer.public_key.as_str(), peer_pub_k.as_str());
|
||||
assert_eq!(parsed.peer.endpoint, "37.19.199.155:51820");
|
||||
assert_eq!(parsed.peer.allowed_ips, "0.0.0.0/0, ::/0");
|
||||
assert_eq!(parsed.peer.persistent_keepalive, Some(25));
|
||||
assert_eq!(parsed.peer.preshared_key, None);
|
||||
|
||||
let iface_id = Uuid::new_v4();
|
||||
let peer_id = Uuid::new_v4();
|
||||
let (iface, peer) = parsed.into_desired_state(iface_id, peer_id);
|
||||
|
||||
assert_eq!(iface.id, iface_id);
|
||||
assert_eq!(iface.name, "proton0");
|
||||
assert_eq!(iface.role, InterfaceRole::Upstream);
|
||||
assert_eq!(iface.listen_port, None);
|
||||
assert!(iface.enabled);
|
||||
|
||||
assert_eq!(peer.id, peer_id);
|
||||
assert_eq!(peer.interface_id, iface_id);
|
||||
assert_eq!(peer.name, "proton0-provider");
|
||||
assert_eq!(peer.allowed_ips, "0.0.0.0/0, ::/0");
|
||||
assert_eq!(
|
||||
peer.server_wireguard_allowed_ips_for_role(InterfaceRole::Upstream),
|
||||
"0.0.0.0/0, ::/0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_omitted_listen_port_remains_unspecified() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
|
||||
assert_eq!(parsed.listen_port, None);
|
||||
assert_eq!(parsed.peer.endpoint, "37.19.199.155:51820");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explicit_listen_port_is_preserved() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\nListenPort = 45000\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
|
||||
assert_eq!(parsed.listen_port, Some(45000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_port_is_not_local_listen_port() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
|
||||
assert_eq!(parsed.listen_port, None);
|
||||
assert!(parsed.peer.endpoint.ends_with(":51820"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dual_stack_address_and_preshared_key() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
let (psk, _) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32, fd00::2/64
|
||||
DNS = 10.2.0.1, 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
PresharedKey = {}
|
||||
AllowedIPs = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
Endpoint = vpn.example.com:51820
|
||||
"#,
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str(),
|
||||
psk.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "vpn0").expect("parse dual stack");
|
||||
assert_eq!(parsed.address_v4.to_string(), "10.2.0.2/32");
|
||||
assert_eq!(
|
||||
parsed.address_v6.map(|ip| ip.to_string()),
|
||||
Some("fd00::2/64".to_string())
|
||||
);
|
||||
assert_eq!(parsed.dns, Some("10.2.0.1, 1.1.1.1".to_string()));
|
||||
assert!(parsed.peer.preshared_key.is_some());
|
||||
assert_eq!(parsed.peer.preshared_key.unwrap().as_str(), psk.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wg0_interface_name() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let err = UpstreamConfigParser::parse(&conf, "wg0").unwrap_err();
|
||||
assert!(err.to_string().contains("reserved name 'wg0'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cardinality_rejections() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
// 0 peers
|
||||
let no_peers = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n",
|
||||
priv_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_peers, "proton0").is_err());
|
||||
|
||||
// 2 peers
|
||||
let multi_peers = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 5.6.7.8:51820
|
||||
"#,
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
let err = UpstreamConfigParser::parse(&multi_peers, "proton0").unwrap_err();
|
||||
assert!(err.to_string().contains("exactly one [Peer] section"));
|
||||
|
||||
// Missing [Interface]
|
||||
let no_iface = format!(
|
||||
"[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_iface, "proton0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_and_malformed_fields_rejections() {
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
// Missing PrivateKey
|
||||
let no_priv = format!(
|
||||
"[Interface]\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_priv, "proton0").is_err());
|
||||
|
||||
// Malformed PrivateKey
|
||||
let bad_priv = format!(
|
||||
"[Interface]\nPrivateKey = not-a-key\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&bad_priv, "proton0").is_err());
|
||||
|
||||
// Missing Address
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let no_addr = format!(
|
||||
"[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_addr, "proton0").is_err());
|
||||
|
||||
// Malformed Address
|
||||
let bad_addr = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 999.999.999.999/99\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&bad_addr, "proton0").is_err());
|
||||
|
||||
// Missing PublicKey
|
||||
let no_pub = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_pub, "proton0").is_err());
|
||||
|
||||
// Missing Endpoint
|
||||
let no_ep = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_ep, "proton0").is_err());
|
||||
|
||||
// Missing AllowedIPs
|
||||
let no_aips = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_aips, "proton0").is_err());
|
||||
|
||||
// Unknown section
|
||||
let unknown_sec = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Unknown]\nKey = Val\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&unknown_sec, "proton0").is_err());
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wireguard::{
|
||||
ClientConfigBuilder, SimulatedWireGuardEngine, WireGuardEngine, generate_qr_ascii,
|
||||
generate_qr_data_url, generate_qr_png_bytes, generate_qr_svg,
|
||||
@@ -23,9 +25,10 @@ async fn test_wireguard_engine_lifecycle_and_telemetry() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub.clone(),
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -138,9 +141,10 @@ fn test_client_config_and_qr_codes() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -6,10 +6,10 @@ The `nx9-wg` API daemon serves JSON REST endpoints and a real-time WebSocket eve
|
||||
|
||||
## 1. Authentication & Session Model
|
||||
|
||||
Authentication is supported via two mechanisms:
|
||||
Authentication is supported via two secure mechanisms:
|
||||
|
||||
### 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
|
||||
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.
|
||||
- `404 Not Found`: Resource ID does not exist in SQLite.
|
||||
- `409 Conflict`: Unique constraint violation (e.g. duplicate interface name or IP).
|
||||
- `422 Unprocessable Entity`: Semantic constraint failure.
|
||||
- `429 Too Many Requests`: Brute-force rate limiting triggered.
|
||||
- `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 (5 failed logins in 15 minutes).
|
||||
- `500 Internal Server Error`: Native Linux execution plane or storage failure.
|
||||
|
||||
---
|
||||
@@ -58,20 +58,23 @@ All non-2xx responses return a structured JSON error body:
|
||||
- `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/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
|
||||
- `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 }`.
|
||||
- `GET /api/v1/interfaces`: List all WireGuard interfaces (includes `role`: `"overlay"` | `"upstream"` and `listen_port`: `u16 | null`).
|
||||
- `POST /api/v1/interfaces`: Create standard Overlay interface `{ "name": "wg0", "address_v4": "10.100.0.1/24", "listen_port": 51820, "mtu": 1420 }`.
|
||||
- `POST /api/v1/interfaces/upstreams/preview`: Dry-run validate and preview third-party WireGuard `.conf` `{ "name": "proton0", "config": "[Interface]\n..." }`. Returns parsed interface and provider peer metadata with secrets redacted. Does not mutate database.
|
||||
- `POST /api/v1/interfaces/upstreams/import`: Import third-party WireGuard `.conf` `{ "name": "proton0", "config": "[Interface]\n..." }`. Atomically creates Upstream interface and provider peer in SQLite, syncs kernel device with dynamic local listen port, and triggers reconciliation.
|
||||
- `GET /api/v1/interfaces/{id}`: Get interface details.
|
||||
- `PUT /api/v1/interfaces/{id}`: Update interface configuration.
|
||||
- `DELETE /api/v1/interfaces/{id}`: Delete interface (cascades to peers).
|
||||
- `PUT /api/v1/interfaces/{id}`: Update interface configuration (preserves private/public cryptographic identity).
|
||||
- `DELETE /api/v1/interfaces/{id}`: Delete interface (tears down kernel device via Netlink and cascades to peers in database; protected against `wg0`).
|
||||
- `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` (protected against `wg0`).
|
||||
- `POST /api/v1/interfaces/{id}/restart`: Restart interface (tears down kernel link and re-synchronizes desired configuration and peers).
|
||||
- `GET /api/v1/interfaces/{id}/status`: Query live kernel netlink telemetry.
|
||||
|
||||
### 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.
|
||||
- `POST /api/v1/interfaces/{id}/peers`: Enroll peer `{ "name": "alice", "profile": "full_tunnel", "mtu": 1280, ... }`.
|
||||
- `GET /api/v1/peers/{id}`: Get peer details.
|
||||
@@ -79,8 +82,12 @@ All non-2xx responses return a structured JSON error body:
|
||||
- `DELETE /api/v1/peers/{id}`: Delete peer.
|
||||
- `POST /api/v1/peers/{id}/enable`: Enable 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}/qr`: Generate QR code SVG/PNG (supports `?qr_format=svg`).
|
||||
- `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&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
|
||||
- `GET /api/v1/networks`: List subnet networks.
|
||||
@@ -95,8 +102,8 @@ All non-2xx responses return a structured JSON error body:
|
||||
- `DELETE /api/v1/routes/{id}`: Delete route.
|
||||
|
||||
### Firewall & NAT
|
||||
- `GET /api/v1/firewall/rules`: List nftables rules.
|
||||
- `POST /api/v1/firewall/rules`: Create rule `{ "name": "allow-ssh", "protocol": "tcp", "port": "22", "action": "accept" }`.
|
||||
- `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" }`.
|
||||
- `DELETE /api/v1/firewall/rules/{id}`: Delete rule.
|
||||
- `POST /api/v1/firewall/rules/{id}/enable`: Enable rule.
|
||||
- `POST /api/v1/firewall/rules/{id}/disable`: Disable rule.
|
||||
@@ -106,7 +113,7 @@ All non-2xx responses return a structured JSON error body:
|
||||
- `POST /api/v1/reconcile/apply`: Execute native mutations and verify convergence.
|
||||
|
||||
### 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.
|
||||
|
||||
### Backups
|
||||
@@ -119,11 +126,14 @@ All non-2xx responses return a structured JSON error body:
|
||||
### Audit Trail
|
||||
- `GET /api/v1/audit`: List append-only security and operational audit records.
|
||||
|
||||
### SPA CLI Console
|
||||
- `POST /api/v1/cli/execute`: Execute a structured read-only CLI command `{ "command": "interface", "subcommand": "upstream", "sub_subcommand": "list", "target": null, "parameters": {} }`. Enforces a strict read-only allowlist and sanitizes output against secret leakage. Mutating commands and arbitrary shell execution are strictly rejected.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
{
|
||||
@@ -102,5 +102,58 @@ flowchart TD
|
||||
1. **Subprocess Isolation**: Zero invocations of `std::process::Command` or shell scripts across the entire production codebase.
|
||||
2. **Persistence Authority**: SQLite remains the single authoritative source of truth. Kernel state is continuously reconciled to match database state.
|
||||
3. **Firewall Isolation**: All nftables operations are confined to `table inet nx9_wg`. Unmanaged host tables are untouched.
|
||||
4. **Route Safety**: Default gateway routes and host networking routes are protected against accidental deletion or flushing.
|
||||
4. **Route Safety**: Default gateway routes and physical host networking routes are protected against accidental deletion or flushing.
|
||||
5. **Secret Redaction**: Private keys, preshared keys, password hashes, and token hashes are masked in `Debug` formatters, CLI outputs, and API responses.
|
||||
|
||||
---
|
||||
|
||||
## 4. Interface Roles & Upstream Architecture
|
||||
|
||||
`nx9-wg` implements explicit `InterfaceRole` categorization across domain models, Netlink device configuration, and reconciliation:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Linux Host Network │
|
||||
│ │
|
||||
│ ┌────────────────────────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ Primary Overlay (wg0) │ │ Optional Upstream (proton0) │ │
|
||||
│ │ Role: Overlay │ │ Role: Upstream │ │
|
||||
│ │ Local Listen Port: 51820 │ │ Local Listen Port: Auto (Dyn) │ │
|
||||
│ │ Peers: 1..N Clients (Mobile) │ │ Peers: Exactly 1 Provider Peer │ │
|
||||
│ │ Cryptokey AllowedIPs: /32 │ │ Cryptokey AllowedIPs: 0/0, ::0 │ │
|
||||
│ └────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌────────────────────────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ Private Overlay Clients │ │ Remote Provider Endpoint │ │
|
||||
│ │ (10.100.0.0/24 Subnet) │ │ (37.19.199.155:51820) │ │
|
||||
│ └────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ Physical WAN Default Route (eno2) │ │
|
||||
│ │ Gateway: 192.168.1.1 (FIB Unchanged) │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### A. Role Discriminator & Invariants
|
||||
- **`InterfaceRole::Overlay`**: The primary private WireGuard overlay network. Exactly one instance exists (`wg0`). It binds to an explicit listen port (`51820`), hosts enrolled client peers, and is protected from deletion or disabling.
|
||||
- **`InterfaceRole::Upstream`**: Optional third-party WireGuard VPN interfaces (e.g. `proton0`). Zero or more instances may exist concurrently. Each Upstream interface connects NX9-WG to an external service provider through exactly one provider peer.
|
||||
|
||||
### B. Optional Local Listen Port & Ephemeral Kernel Binding
|
||||
- `Interface.listen_port` is modeled as `Option<u16>`.
|
||||
- Standard third-party `.conf` imports (e.g. ProtonVPN) omit `[Interface] ListenPort`. NX9-WG preserves `listen_port = None` without defaulting to `51820`.
|
||||
- In `configure_device`, omitting the `WireguardAttribute::ListenPort` Netlink attribute signals the Linux kernel to assign an ephemeral dynamic UDP port automatically.
|
||||
- This prevents local UDP port contention and allows `wg0` (51820) and `proton0` (dynamic) to coexist without `-EADDRINUSE` errors.
|
||||
- Dynamic kernel ports produce 0 false drift actions in the reconciliation engine when desired `listen_port` is `None`.
|
||||
|
||||
### C. Cryptokey Routing vs. Linux FIB Default Routes
|
||||
- An Upstream provider peer often specifies `AllowedIPs = 0.0.0.0/0, ::/0` in its `.conf`.
|
||||
- In WireGuard, `AllowedIPs` defines the device-level cryptokey packet routing filter; it does **not** install a Linux kernel route.
|
||||
- NX9-WG preserves `0.0.0.0/0, ::/0` on the `proton0` WireGuard device without modifying the server's Linux FIB default gateway (`192.168.1.1`).
|
||||
- The provider endpoint (`37.19.199.155:51820`) remains reachable via the host physical WAN interface.
|
||||
|
||||
### D. NAT Masquerade Isolation
|
||||
- Outbound NAT masquerading compiled into `table inet nx9_wg` is strictly scoped to Overlay client subnets (`10.100.0.0/24`).
|
||||
- Upstream tunnel addresses (`10.2.0.2/32`) are not treated as client subnets and do not trigger unsolicited global masquerading.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Native CLI Command Reference (`nx9-wg`)
|
||||
|
||||
The `nx9-wg` binary provides native CLI coverage across all 18 application command groups without spawning external subprocesses.
|
||||
|
||||
---
|
||||
|
||||
## 1. Global Options
|
||||
|
||||
| Option | Environment Variable | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `-c, --config <PATH>` | `NX9_WG_CONFIG` | Path to configuration file (default: `/etc/nx9-wg/config.toml`) |
|
||||
| `-d, --data-dir <PATH>` | `NX9_WG_DATA_DIR` | Path to data directory (default: `/var/lib/nx9-wg`) |
|
||||
| `--database <PATH>` | `NX9_WG_DATABASE` | Specific SQLite database file path or URL |
|
||||
| `--format <FORMAT>` | N/A | Output format (`table`, `json`, `yaml`, `csv`, default: `table`) |
|
||||
| `--json` | N/A | Convenience flag for strict JSON output |
|
||||
| `-q, --quiet` | N/A | Suppress status and conversational messages |
|
||||
| `-v, --verbose` | N/A | Enable verbose trace logging |
|
||||
| `--log-level <LEVEL>` | `NX9_WG_LOG_LEVEL` | Log verbosity level (`trace`, `debug`, `info`, `warn`, `error`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Command Groups Reference
|
||||
|
||||
### 1. `version`
|
||||
Displays version, build edition, architecture, OS platform, and security flags.
|
||||
```bash
|
||||
nx9-wg version
|
||||
nx9-wg version --format json
|
||||
```
|
||||
|
||||
### 2. `serve`
|
||||
Starts the Axum REST API daemon, WebSocket streamer, and background reconciliation scheduler.
|
||||
```bash
|
||||
nx9-wg serve
|
||||
nx9-wg serve --bind 0.0.0.0:8080
|
||||
```
|
||||
|
||||
### 3. `init`
|
||||
Initializes the single administrator account across bootstrap sources.
|
||||
```bash
|
||||
# Generate secure random password written to a restricted file:
|
||||
nx9-wg init --generate-password --write-password-file /var/lib/nx9-wg/admin-password
|
||||
|
||||
# Password via stdin:
|
||||
echo "StrongPassword123!" | nx9-wg init --password-stdin
|
||||
|
||||
# Password from file:
|
||||
nx9-wg init --password-file /run/secrets/admin_pw
|
||||
```
|
||||
|
||||
### 4. `system`
|
||||
- `nx9-wg system status`: System database statistics and object counts.
|
||||
- `nx9-wg system health`: System and SQLite connectivity health check.
|
||||
- `nx9-wg system info`: System platform, architecture, and runtime paths.
|
||||
- `nx9-wg system settings list`: List all key-value settings.
|
||||
- `nx9-wg system settings get <KEY>`: Query setting value.
|
||||
- `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.
|
||||
|
||||
```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`
|
||||
- `nx9-wg admin status`: Query administrator account metadata.
|
||||
- `nx9-wg admin create [--username U] [--password P | --password-stdin | --generate-password]`: Bootstrap admin if uninitialized.
|
||||
- `nx9-wg admin password [--new-password P | --stdin | --password-file F | --generate]`: Change administrator password.
|
||||
- `nx9-wg admin tokens create --name <NAME> [--days N] [--write-token-file PATH]`: Generate API token.
|
||||
- `nx9-wg admin tokens list`: List active API tokens.
|
||||
- `nx9-wg admin tokens revoke <TOKEN_ID>`: Revoke an API token.
|
||||
- `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`
|
||||
- `nx9-wg interface list`: List all WireGuard interfaces (displays Role: Overlay vs Upstream).
|
||||
- `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 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 disable <NAME_OR_ID>`: Disable interface (`IFF_DOWN`; `wg0` cannot be disabled).
|
||||
- `nx9-wg interface restart <NAME_OR_ID>`: Restart interface (tears down kernel device and re-applies desired configuration and peers).
|
||||
- `nx9-wg interface delete <NAME_OR_ID>`: Delete interface (removes kernel device via Netlink and cascades to peers in database; `wg0` cannot be deleted).
|
||||
- `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.
|
||||
- `nx9-wg interface upstream list`: List all Upstream WireGuard interfaces.
|
||||
- `nx9-wg interface upstream show <NAME_OR_ID>`: Show Upstream interface configuration and provider peer details.
|
||||
- `nx9-wg interface upstream import <NAME> [--file <PATH> | --config <CONF_STR>]`: Import third-party WireGuard `.conf` configuration (e.g. ProtonVPN) and create an Upstream interface.
|
||||
- `nx9-wg interface upstream status <NAME_OR_ID>`: Show live kernel status and handshake for an Upstream interface.
|
||||
- `nx9-wg interface upstream enable <NAME_OR_ID>`: Enable an Upstream interface.
|
||||
- `nx9-wg interface upstream disable <NAME_OR_ID>`: Disable an Upstream interface.
|
||||
- `nx9-wg interface upstream restart <NAME_OR_ID>`: Restart an Upstream interface (teardown + re-sync).
|
||||
- `nx9-wg interface upstream delete <NAME_OR_ID>`: Delete an Upstream interface.
|
||||
|
||||
### 7. `peer`
|
||||
- `nx9-wg peer list [--interface NAME_OR_ID]`: List enrolled peers.
|
||||
- `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 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 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> [--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. `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 show <ID>`: Show network details.
|
||||
- `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.
|
||||
|
||||
### 10. `route`
|
||||
- `nx9-wg route list`: List configured routing rules.
|
||||
- `nx9-wg route show <ID>`: Show route details.
|
||||
- `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.
|
||||
|
||||
### 11. `firewall`
|
||||
- `nx9-wg firewall list [--peer PEER]`: List configured nftables rules.
|
||||
- `nx9-wg firewall show <ID>`: Show firewall rule details.
|
||||
- `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 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.
|
||||
|
||||
### 12. `nat`
|
||||
- `nx9-wg nat status`: Query NAT masquerade state.
|
||||
- `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.
|
||||
|
||||
### 13. `forwarding`
|
||||
- `nx9-wg forwarding status`: Query kernel `/proc/sys/net/ipv4/ip_forward` status.
|
||||
- `nx9-wg forwarding enable` / `disable`: Toggle kernel IP packet forwarding.
|
||||
- `nx9-wg forwarding sync`: Synchronize IP forwarding setting with kernel.
|
||||
|
||||
### 14. `reconcile`
|
||||
- `nx9-wg reconcile status`: Inspect reconciliation status and statistics.
|
||||
- `nx9-wg reconcile plan [--interface IFACE]`: Calculate read-only drift plan between SQLite and Linux kernel.
|
||||
- `nx9-wg reconcile apply [--interface IFACE]`: Apply reconciliation mutations to live kernel state.
|
||||
- `nx9-wg reconcile verify`: Verify zero drift between SQLite and kernel.
|
||||
|
||||
### 15. `backup`
|
||||
- `nx9-wg backup list`: List backup snapshots.
|
||||
- `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 restore <PATH> [-y, --yes]`: Restore database with automatic safety snapshot.
|
||||
- `nx9-wg backup delete <ID>`: Delete backup record and snapshot archive.
|
||||
|
||||
### 16. `audit`
|
||||
- `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.
|
||||
|
||||
### 17. `live`
|
||||
- `nx9-wg live interface list`: List live WireGuard interface names in kernel.
|
||||
- `nx9-wg live interface show <NAME>`: Show live interface statistics.
|
||||
- `nx9-wg live peer <IFACE>`: Query live peers, transfer bytes, and handshakes.
|
||||
- `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.
|
||||
|
||||
### 18. `diagnostics`
|
||||
- `nx9-wg diagnostics all`: Inspect health across all subsystems.
|
||||
- `nx9-wg diagnostics <SUBSYSTEM> [--peer PEER_UUID]`: Inspect specific subsystem (`system`, `network`, `wan`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `mtu`, `reconciliation`).
|
||||
@@ -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
|
||||
|
||||
- **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`.
|
||||
|
||||
@@ -53,7 +53,7 @@ The `port_range` field supports three RFC-compliant formats:
|
||||
NAT masquerading is governed by key-value appliance settings in SQLite:
|
||||
|
||||
- **`enable_nat`**: Boolean string (`"true"` / `"false"`). When enabled, all active managed WireGuard subnets are masqueraded outbound to the host WAN interface.
|
||||
- **Dynamic Subnet Calculation**: The reconciliation engine queries all enabled interfaces (`Interface.address_v4`) and generates dedicated masquerade rules for each unique subnet.
|
||||
- **Dynamic Subnet Calculation**: The reconciliation engine queries enabled Interface address CIDRs and enabled Subnet Network CIDRs, then generates dedicated masquerade rules for each unique subnet. Interface addresses remain the WireGuard transport identity; Network CIDRs are the peer allocation domains.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,8 +23,8 @@ Download and extract the official release archive:
|
||||
|
||||
```bash
|
||||
# 1. Download release archive (replace with current version/arch)
|
||||
tar -xzf nx9-wg-v1.0.0-linux-x86_64.tar.gz
|
||||
cd nx9-wg-v1.0.0-linux-x86_64
|
||||
tar -xzf nx9-wg-v1.1.0-linux-x86_64.tar.gz
|
||||
cd nx9-wg-v1.1.0-linux-x86_64
|
||||
|
||||
# 2. Run the automated installer as root
|
||||
sudo bash install.sh
|
||||
@@ -29,13 +29,14 @@ Unlike traditional WireGuard management tools that spawn external CLI processes
|
||||
|
||||
### B. WireGuard Generic Netlink Protocol
|
||||
- Resolves the dynamic Generic Netlink family ID for `"wireguard"`.
|
||||
- **`WG_CMD_SET_DEVICE`**: Atomically configures the interface private key, UDP listen port, and peer list.
|
||||
- **`WG_CMD_SET_DEVICE`**: Atomically configures the interface private key, UDP listen port (if explicitly configured), and peer list.
|
||||
- **Optional ListenPort**: If `interface.listen_port` is `Some(port)` and `port != 0`, `WGDEVICE_A_LISTEN_PORT` is emitted. If `None` (standard for Upstream interfaces like `proton0`), the attribute is omitted, allowing the Linux kernel to automatically bind an ephemeral dynamic UDP port.
|
||||
- **`WG_CMD_GET_DEVICE`**: Queries live kernel device state, active listen port, public key, peer public keys, endpoints, allowed IPs, last handshake timestamps, and transfer byte counters.
|
||||
- **`WGDEVICE_F_REPLACE_PEERS`**: When syncing peers, setting this flag instructs the kernel to atomically replace all existing peers with the supplied desired set, removing stale peers in a single transaction.
|
||||
|
||||
---
|
||||
|
||||
## 2. Peer Cryptographic Synchronization
|
||||
## 2. Peer Cryptographic Synchronization & Role-Aware AllowedIPs
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -45,9 +46,9 @@ sequenceDiagram
|
||||
participant Kernel as Linux Kernel (wireguard.ko)
|
||||
|
||||
Engine->>Genl: Send WG_CMD_SET_DEVICE (Interface wg0, ReplacePeers=true)
|
||||
Note over Engine,Genl: Encodes ListenPort, PrivateKey, Peer Array
|
||||
Note over Engine,Genl: Encodes ListenPort (if Some), PrivateKey, Peer Array
|
||||
Genl->>Kernel: Transmit Netlink Message
|
||||
Kernel->>Kernel: Validate Keys, Bind UDP Port, Apply Peers
|
||||
Kernel->>Kernel: Validate Keys, Bind UDP Port (or dynamic), Apply Peers
|
||||
Kernel-->>Genl: NLMSG_ERROR (error=0 / Success)
|
||||
Genl-->>Engine: Ok(())
|
||||
|
||||
@@ -57,10 +58,12 @@ sequenceDiagram
|
||||
Genl-->>Engine: Live Telemetry (Handshakes, Bytes Tx/Rx)
|
||||
```
|
||||
|
||||
### Cryptographic Attribute Encoding:
|
||||
### Role-Aware Cryptographic Attribute Encoding:
|
||||
- **Keys**: 32-byte binary Curve25519 keys (`WGPEER_A_PUBLIC_KEY`, `WGPEER_A_PRESHARED_KEY`).
|
||||
- **Allowed IPs**: Nested attributes (`WGALLOWEDIP_A_FAMILY`, `WGALLOWEDIP_A_IPADDR`, `WGALLOWEDIP_A_CIDR_MASK`).
|
||||
- **Endpoint**: `sockaddr_in` (IPv4) or `sockaddr_in6` (IPv6) socket address structures.
|
||||
- **Role-Aware Allowed IPs**:
|
||||
- **Overlay Peers**: Scoped to `/32` (IPv4) or `/128` (IPv6) derived from the peer's assigned tunnel address.
|
||||
- **Upstream Provider Peers**: Preserves full-tunnel AllowedIPs (`0.0.0.0/0, ::/0`) on the WireGuard device without modifying the server's Linux FIB default routing table.
|
||||
- **Endpoint**: `sockaddr_in` (IPv4) or `sockaddr_in6` (IPv6) socket address structures representing the remote destination (e.g. `37.19.199.155:51820`), independent of the local interface listen port.
|
||||
- **Persistent Keepalive**: Interval in seconds (`WGPEER_A_PERSISTENT_KEEPALIVE_INTERVAL`).
|
||||
|
||||
---
|
||||
@@ -66,8 +66,9 @@ table inet nx9_wg {
|
||||
|
||||
Outbound NAT masquerading is dynamically scoped exclusively to managed WireGuard client subnets:
|
||||
1. **Subnet Deduplication**: Overlapping subnets are merged to prevent redundant rules.
|
||||
2. **Interface Exclusion**: Traffic routing back into the WireGuard interface (`oifname != "wg0"`) is not masqueraded to preserve true source IPs for site-to-site tunnels.
|
||||
2. **Interface Exclusion**: Traffic routing back into the WireGuard interface (`oifname != "wg*"`) is not masqueraded to preserve true source IPs for site-to-site tunnels.
|
||||
3. **No Catch-All Masquerade**: `nx9-wg` never creates a catch-all `masquerade` rule that affects non-WireGuard traffic on the host.
|
||||
4. **Interface and Subnet Network CIDRs**: Masquerade sources include each enabled Interface address CIDR and each enabled Subnet Network CIDR. A peer allocated from a selected Network (for example outside the WireGuard interface `/24`) is masqueraded from that Network CIDR; the Interface address itself is unchanged.
|
||||
|
||||
---
|
||||
|
||||
@@ -5,38 +5,38 @@ Welcome to the official documentation for the **NX9 WireGuard (`nx9-wg`)** appli
|
||||
---
|
||||
|
||||
## 1. Getting Started & Philosophy
|
||||
- [**NX9 Design Principles**](design-principles.md) — Architectural philosophy, self-hosted sovereignty, zero scripting runtime, and SQLite authority.
|
||||
- [**Installation & Deployment Guide**](installation.md) — Production installation, systemd service, admin bootstrap, first interface, and peer setup.
|
||||
- [**Linux Platform & Kernel Requirements**](linux_requirements.md) — Kernel 5.6+, in-tree WireGuard module, Netlink sockets, `libnftables.so.1`, and capabilities.
|
||||
- [**NX9 Design Principles**](DESIGN-PRINCIPLES.md) — Architectural philosophy, self-hosted sovereignty, zero scripting runtime, and SQLite authority.
|
||||
- [**Installation & Deployment Guide**](INSTALLATION.md) — Production installation, systemd service, admin bootstrap, first interface, and peer setup.
|
||||
- [**Linux Platform & Kernel Requirements**](LINUX_REQUIREMENTS.md) — Kernel 5.6+, in-tree WireGuard module, Netlink sockets, `libnftables.so.1`, and capabilities.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & Native Linux Execution
|
||||
- [**System Architecture & Workspace Structure**](architecture.md) — Six-crate workspace breakdown, layer boundaries, and end-to-end data flows.
|
||||
- [**Native WireGuard Netlink Engine**](native-wireguard.md) — Direct RTNETLINK and Generic Netlink (`wireguard`) protocol implementation.
|
||||
- [**Native Network & Routing Engine**](native-network.md) — RTNETLINK link/address/route lifecycle and direct procfs IP packet forwarding.
|
||||
- [**Native nftables Engine**](nftables.md) — In-process `libnftables.so.1` FFI transactions and dedicated `table inet nx9_wg` scoping.
|
||||
- [**Firewall & NAT Domain Model**](firewall_nat.md) — Typed rules, protocol groups, port ranges, priorities, and outbound NAT masquerading.
|
||||
- [**System Architecture & Workspace Structure**](ARCHITECTURE.md) — Six-crate workspace breakdown, layer boundaries, and end-to-end data flows.
|
||||
- [**Native WireGuard Netlink Engine**](NATIVE-WIREGUARD.md) — Direct RTNETLINK and Generic Netlink (`wireguard`) protocol implementation.
|
||||
- [**Native Network & Routing Engine**](NATIVE-NETWORK.md) — RTNETLINK link/address/route lifecycle and direct procfs IP packet forwarding.
|
||||
- [**Native nftables Engine**](NFTABLES.md) — In-process `libnftables.so.1` FFI transactions and dedicated `table inet nx9_wg` scoping.
|
||||
- [**Firewall & NAT Domain Model**](FIREWALL_NAT.md) — Typed rules, protocol groups, port ranges, priorities, and outbound NAT masquerading.
|
||||
|
||||
---
|
||||
|
||||
## 3. Control Plane, UI & Telemetry
|
||||
- [**Reconciliation Engine & Convergence**](reconciliation.md) — Closed-loop drift detection, read-only planning, serialized apply, and convergence lifecycle states.
|
||||
- [**Web User Interface (SPA)**](ui.md) — Zero-dependency embedded HTML5/CSS/JS frontend, theme engine, and all 15 application routes.
|
||||
- [**Axum REST API & WebSocket Protocol**](api.md) — Complete endpoint reference, JSON schemas, error handling, and real-time event broadcaster.
|
||||
- [**Native CLI Command Reference**](cli.md) — Full reference for all 17 CLI subcommands, multi-format output (`table`/`json`/`yaml`/`csv`), and secret files.
|
||||
- [**Reconciliation Engine & Convergence**](RECONCILIATION.md) — Closed-loop drift detection, read-only planning, serialized apply, and convergence lifecycle states.
|
||||
- [**Web User Interface (SPA)**](UI.md) — Zero-dependency embedded HTML5/CSS/JS frontend, theme engine, and all 15 application routes.
|
||||
- [**Axum REST API & WebSocket Protocol**](API.md) — Complete endpoint reference, JSON schemas, error handling, and real-time event broadcaster.
|
||||
- [**Native CLI Command Reference**](CLI.md) — Full reference for all 18 CLI subcommands, multi-format output (`table`/`json`/`yaml`/`csv`), and secret files.
|
||||
|
||||
---
|
||||
|
||||
## 4. Security & Disaster Recovery
|
||||
- [**Security Model & Privilege Architecture**](security.md) — Single admin model (`CHECK (id=1)`), Argon2id hashing, SHA-256 tokens, permissions matrix, and brute-force protection.
|
||||
- [**Backup & Disaster Recovery Guide**](backup_restore.md) — Atomic online SQLite backups (`VACUUM INTO`), SHA-256 manifests, and pre-restore safety snapshots.
|
||||
- [**Security Model & Privilege Architecture**](SECURITY.md) — Single admin model (`CHECK (id=1)`), Argon2id hashing, SHA-256 tokens, permissions matrix, and brute-force protection.
|
||||
- [**Backup & Disaster Recovery Guide**](BACKUP_RESTORE.md) — Atomic online SQLite backups (`VACUUM INTO`), SHA-256 manifests, and pre-restore safety snapshots.
|
||||
|
||||
---
|
||||
|
||||
## 5. Operations, Development & Release
|
||||
- [**Release Engineering & Packaging**](release.md) — Standalone distribution packages (`.tar.gz`/`.tar.xz`), systemd sandboxing, installer, and rollback strategy.
|
||||
- [**Release Engineering & Packaging**](RELEASE.md) — Standalone distribution packages (`.tar.gz`/`.tar.xz`), systemd sandboxing, installer, and rollback strategy.
|
||||
- [**Comprehensive Testing Specification**](TESTING.md) — Multi-tiered test suites, SAFE mode (`LIVE=0`) vs real-kernel mode (`LIVE=1`), and automated security audits.
|
||||
- [**Developer & Contributing Guide**](development.md) — Building, testing, linting, and workspace contribution standards.
|
||||
- [**Configuration Reference**](configuration.md) — TOML configuration format and `NX9_WG_*` environment variable precedence.
|
||||
- [**Docker & Container Deployment**](docker.md) — Containerized deployment with Linux capability isolation and volume persistence.
|
||||
- [**Developer & Contributing Guide**](DEVELOPMENT.md) — Building, testing, linting, and workspace contribution standards.
|
||||
- [**Configuration Reference**](CONFIGURATION.md) — TOML configuration format and `NX9_WG_*` environment variable precedence.
|
||||
- [**Docker & Container Deployment**](DOCKER.md) — Containerized deployment with Linux capability isolation and volume persistence.
|
||||
@@ -72,19 +72,24 @@ pub struct ReconciliationPlan {
|
||||
|
||||
### A. WireGuard Interfaces
|
||||
- Checks if desired interfaces (`Interface`) exist in kernel links via RTNETLINK.
|
||||
- Detects missing interfaces, wrong MTU, or down status.
|
||||
- Detects missing interfaces, wrong MTU, down status, or public key mismatch.
|
||||
- **Dynamic Port Drift Tolerance**: When desired `listen_port` is `None` (standard for Upstream interfaces), the reconciler accepts kernel-selected ephemeral dynamic ports without generating false drift.
|
||||
- **Orphan Interface Detection**: Scans live kernel WireGuard interfaces; any interface present in kernel but absent from SQLite desired state is scheduled for removal (`delete_orphan_interface`).
|
||||
|
||||
### B. Cryptographic Peers
|
||||
- Queries live WireGuard device via `WG_CMD_GET_DEVICE`.
|
||||
- Detects missing peers, changed public keys, altered allowed IPs, or mismatched persistent keepalive intervals.
|
||||
- **Role-Aware Cryptokey Routing**: Overlay peers are checked against assigned `/32` or `/128` tunnel addresses, while Upstream provider peers are checked against configured full-tunnel AllowedIPs (`0.0.0.0/0, ::/0`).
|
||||
|
||||
### C. Kernel Routes
|
||||
- Queries active kernel routes via `RTM_GETROUTE`.
|
||||
- Evaluates exact equality on destination CIDR, gateway IP, interface name, and route metric.
|
||||
- Protects host default gateway (`192.168.1.1`) and physical WAN interfaces from unwanted modifications.
|
||||
|
||||
### D. nftables Firewall & NAT
|
||||
- Compares desired rules in SQLite against live rules in `table inet nx9_wg`.
|
||||
- Detects missing rules, priority shifts, or altered NAT masquerade subnet policies.
|
||||
- Outbound NAT masquerading remains scoped exclusively to Overlay client subnets.
|
||||
|
||||
### E. IP Forwarding
|
||||
- Inspects `/proc/sys/net/ipv4/ip_forward` and `/proc/sys/net/ipv6/conf/all/forwarding`.
|
||||
@@ -105,3 +110,10 @@ Reconciliation mutations are protected by an asynchronous Mutex:
|
||||
1. **Clean Cold-Start Recovery**: When `nx9-wg` starts or restarts, the background daemon queries the kernel, detects unapplied state from SQLite, and applies all interfaces, peers, routes, and firewall rules in one unified cycle.
|
||||
2. **Idempotent Convergence**: Running `reconcile apply` multiple times in succession produces zero mutations (NOOP) once convergence is achieved.
|
||||
3. **Telemetry Protection**: Live kernel telemetry (transfer bytes, handshake timestamps) is ingested into memory/events and NEVER overwrites authoritative desired configuration in SQLite.
|
||||
|
||||
---
|
||||
|
||||
## 6. Orphan Interface Removal & Empty-State Guard
|
||||
|
||||
- **Deterministic Orphan Cleanup**: When an interface is deleted or an unmanaged kernel device is detected, `apply()` removes the orphan interface from the Linux kernel.
|
||||
- **Empty-Desired-State Safety Guard**: If SQLite returns zero desired interfaces while live kernel interfaces are present, `apply()` aborts immediately with an error rather than mass-deleting kernel interfaces, protecting against catastrophic link destruction during transient database read errors.
|
||||
@@ -6,29 +6,29 @@ This document describes the release packaging, artifact verification, filesystem
|
||||
|
||||
## 1. Release Packaging Pipeline
|
||||
|
||||
Release archives are generated using [`scripts/package-release.sh`](file:///home/sunil/Programs/nx9-wg/scripts/package-release.sh):
|
||||
Release archives are generated using [`scripts/package-release.sh`](../scripts/package-release.sh):
|
||||
|
||||
```bash
|
||||
bash scripts/package-release.sh
|
||||
```
|
||||
|
||||
### Packaging Outputs in `target/dist/`:
|
||||
- `nx9-wg-v1.0.0-linux-x86_64.tar.gz` (Standard gzip archive)
|
||||
- `nx9-wg-v1.0.0-linux-x86_64.tar.xz` (High-compression XZ archive)
|
||||
- `nx9-wg-v1.0.0-linux-x86_64.sha256` (Cryptographic SHA-256 checksums)
|
||||
- `nx9-wg-v1.1.0-linux-x86_64.tar.gz` (Standard gzip archive)
|
||||
- `nx9-wg-v1.1.0-linux-x86_64.tar.xz` (High-compression XZ archive)
|
||||
- `nx9-wg-v1.1.0-linux-x86_64.sha256` (Cryptographic SHA-256 checksums)
|
||||
|
||||
---
|
||||
|
||||
## 2. Release Documentation
|
||||
|
||||
The release documentation is versioned with the source tree. `CHANGELOG.md` records release-level changes, while `docs/TESTING.md` is the authoritative v1.0.0 testing and acceptance specification.
|
||||
The release documentation is versioned with the source tree. `CHANGELOG.md` records release-level changes, while `docs/TESTING.md` is the authoritative v1.1.0 testing and acceptance specification.
|
||||
|
||||
## 3. Release Archive Contents
|
||||
|
||||
Every release archive contains everything required for a standalone, offline production deployment:
|
||||
|
||||
```
|
||||
nx9-wg-v1.0.0-linux-x86_64/
|
||||
nx9-wg-v1.1.0-linux-x86_64/
|
||||
├── nx9-wg (Native executable binary, mode 0755)
|
||||
├── nx9-wg.service (Hardened systemd unit file, mode 0644)
|
||||
├── config.example.toml (Production configuration template, mode 0644)
|
||||
@@ -57,8 +57,11 @@
|
||||
## 6. Secret Redaction & Memory Safety
|
||||
|
||||
- Custom `std::fmt::Debug` implementations enforce `[REDACTED]` for `WireGuardPrivateKey`, `WireGuardPresharedKey`, `Admin`, and `ApiToken`.
|
||||
- **Upstream Import Secret Safety**: Third-party `.conf` previews and import responses never return private keys or preshared keys in cleartext. Sensitive keys are stored strictly in the database and submitted to the kernel over Netlink.
|
||||
- **Reconciliation Plan & Report Scrubbing**: Dry-run plans and reconciliation convergence reports scrub private keys and preshared keys to prevent accidental leakage into logs or event streams.
|
||||
- **SPA CLI Console Output Sanitization**: The read-only SPA CLI execution endpoint runs an automated secret scrubber over command outputs, stripping private keys and credentials before returning output to the browser.
|
||||
- Web UI and REST API responses redact private keys and token hashes.
|
||||
- CLI status output strictly redacts sensitive hashes.
|
||||
- CLI status output strictly redacts sensitive cryptographic keys and password hashes.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
# NX9-WG Stress Test --- Remote LAN Multi-Path Integration
|
||||
|
||||
**Project:** NX9-WG\
|
||||
**Test Type:** Integration / Stress Test\
|
||||
**Status:** PASS\
|
||||
**Date:** 2026-08-19\
|
||||
**Purpose:** Validate robust remote access to a protected LAN through
|
||||
NX9-WG across substantially different underlying network paths.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This test validates that `nx9-wg` can provide authenticated, routed
|
||||
access to a remote `192.168.1.0/24` LAN while the WireGuard client uses
|
||||
different and independent Internet/access-network paths.
|
||||
|
||||
The test deliberately exercised NX9-WG through:
|
||||
|
||||
1. Cellular 5G → mobile WAN AP → laptop.
|
||||
2. Airtel Wi-Fi → Pixel Wi-Fi Station + Access Point concurrency →
|
||||
laptop.
|
||||
3. A separate Wi-Fi network → laptop.
|
||||
|
||||
In all tested paths, the same NX9-WG peer successfully established the
|
||||
VPN and reached hosts and services on the protected LAN.
|
||||
|
||||
The strongest validation was successful interactive SSH access to
|
||||
`192.168.1.200`, in addition to ICMP, HTTP, network filesystem access,
|
||||
and LAN host discovery.
|
||||
|
||||
**Overall result: PASS.**
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 2. Test Objective
|
||||
|
||||
Validate that NX9-WG:
|
||||
|
||||
- establishes a WireGuard tunnel independently of the client's
|
||||
underlying access network;
|
||||
- correctly routes traffic from a remote peer to the protected LAN;
|
||||
- handles different upstream NAT/network topologies;
|
||||
- provides access to multiple LAN hosts rather than only a single
|
||||
endpoint;
|
||||
- supports real application traffic over the tunnel;
|
||||
- preserves connectivity when the client changes access-network
|
||||
topology;
|
||||
- does not require changes to the protected LAN or ISP router
|
||||
configuration.
|
||||
|
||||
This test is intended as an **integration and robustness validation**,
|
||||
not as a throughput benchmark.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 3. Network Topology
|
||||
|
||||
### Protected LAN
|
||||
|
||||
``` text
|
||||
192.168.1.0/24
|
||||
```
|
||||
|
||||
Representative hosts:
|
||||
|
||||
``` text
|
||||
192.168.1.1 Airtel AirFiber / Nokia AAP321NK
|
||||
192.168.1.200 Debian server / Thakares IoT Hub
|
||||
```
|
||||
|
||||
### NX9-WG peer
|
||||
|
||||
``` text
|
||||
WireGuard interface: Office-Laptop
|
||||
WireGuard address: 10.100.0.6/32
|
||||
MTU: 1280
|
||||
```
|
||||
|
||||
The important architectural property is that the client-side underlay
|
||||
network can change while the WireGuard overlay identity and protected
|
||||
LAN remain unchanged.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 4. Test Scenario A --- Cellular 5G
|
||||
|
||||
## Path
|
||||
|
||||
``` text
|
||||
5G Internet
|
||||
│
|
||||
▼
|
||||
Mobile phone
|
||||
│
|
||||
│ WAN AP / hotspot
|
||||
▼
|
||||
Laptop
|
||||
│
|
||||
│ NX9-WG
|
||||
▼
|
||||
NX9-WG server
|
||||
│
|
||||
│ routed LAN access
|
||||
▼
|
||||
192.168.1.0/24
|
||||
```
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Mobile phone connected to cellular 5G.
|
||||
2. Mobile phone provided WAN/AP connectivity.
|
||||
3. Laptop connected to the mobile AP.
|
||||
4. NX9-WG VPN was enabled.
|
||||
5. Laptop received an Internet address on the mobile network.
|
||||
6. Remote LAN routes became reachable through NX9-WG.
|
||||
|
||||
## Observed client addressing
|
||||
|
||||
``` text
|
||||
wlan0: 10.137.106.48/24
|
||||
WireGuard: 10.100.0.6/32
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Internet
|
||||
|
||||
``` text
|
||||
ping google.com
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
``` text
|
||||
0% packet loss
|
||||
```
|
||||
|
||||
### LAN host
|
||||
|
||||
``` text
|
||||
ping 192.168.1.200
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
``` text
|
||||
0% packet loss
|
||||
```
|
||||
|
||||
### LAN discovery
|
||||
|
||||
An initial ordinary `nmap -sP 192.168.1.0/24` did not discover hosts
|
||||
while the LAN was only reachable through the routed WireGuard path.
|
||||
Individual routed hosts were nevertheless reachable.
|
||||
|
||||
This was subsequently validated more comprehensively in Scenario C using
|
||||
`nmap` with the active routed path.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 5. Test Scenario B --- Wi-Fi STA + AP Concurrency
|
||||
|
||||
This was the more interesting underlay test.
|
||||
|
||||
The Pixel device supports simultaneous Wi-Fi client (STA) and Access
|
||||
Point operation.
|
||||
|
||||
## Path
|
||||
|
||||
``` text
|
||||
Airtel Wi-Fi
|
||||
│
|
||||
▼
|
||||
Pixel Wi-Fi STA
|
||||
│
|
||||
Pixel Wi-Fi AP
|
||||
│
|
||||
▼
|
||||
Laptop
|
||||
│
|
||||
NX9-WG
|
||||
│
|
||||
▼
|
||||
NX9-WG server
|
||||
│
|
||||
▼
|
||||
192.168.1.0/24
|
||||
```
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Pixel connected to Airtel Wi-Fi.
|
||||
2. Pixel simultaneously enabled its Access Point.
|
||||
3. Laptop connected to the Pixel AP.
|
||||
4. Laptop enabled NX9-WG.
|
||||
5. No cellular Internet was used.
|
||||
6. Remote LAN access worked immediately.
|
||||
|
||||
## Result
|
||||
|
||||
**PASS**
|
||||
|
||||
This demonstrates that NX9-WG remains functional when the client reaches
|
||||
the Internet through a Wi-Fi STA/AP-concurrent intermediate device.
|
||||
|
||||
No cellular fallback was required.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 6. Test Scenario C --- Remote LAN Application and Host Validation
|
||||
|
||||
A further test was performed from an external Wi-Fi network.
|
||||
|
||||
## Client addressing
|
||||
|
||||
``` text
|
||||
wlan0:
|
||||
10.24.3.48/24
|
||||
|
||||
WireGuard:
|
||||
10.100.0.6/32
|
||||
```
|
||||
|
||||
The underlying Wi-Fi network therefore differed from the protected LAN.
|
||||
|
||||
## Internet validation
|
||||
|
||||
``` bash
|
||||
ping nx9.in
|
||||
```
|
||||
|
||||
Observed:
|
||||
|
||||
``` text
|
||||
5 packets transmitted
|
||||
5 received
|
||||
0% packet loss
|
||||
|
||||
min/avg/max/mdev:
|
||||
82.653 / 93.637 / 101.691 / 7.049 ms
|
||||
```
|
||||
|
||||
## Public Internet validation
|
||||
|
||||
``` bash
|
||||
ping google.com
|
||||
```
|
||||
|
||||
Observed:
|
||||
|
||||
``` text
|
||||
5 packets transmitted
|
||||
5 received
|
||||
0% packet loss
|
||||
|
||||
min/avg/max/mdev:
|
||||
87.633 / 118.502 / 165.108 / 30.034 ms
|
||||
```
|
||||
|
||||
## Airtel gateway validation
|
||||
|
||||
``` bash
|
||||
ping 192.168.1.1
|
||||
```
|
||||
|
||||
Observed:
|
||||
|
||||
``` text
|
||||
2 packets transmitted
|
||||
2 received
|
||||
0% packet loss
|
||||
|
||||
min/avg/max/mdev:
|
||||
98.975 / 99.509 / 100.043 / 0.534 ms
|
||||
```
|
||||
|
||||
## LAN server validation
|
||||
|
||||
``` bash
|
||||
ping 192.168.1.200
|
||||
```
|
||||
|
||||
Observed:
|
||||
|
||||
``` text
|
||||
3 packets transmitted
|
||||
3 received
|
||||
0% packet loss
|
||||
|
||||
min/avg/max/mdev:
|
||||
88.959 / 95.690 / 105.145 / 6.882 ms
|
||||
```
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 7. LAN Host Discovery
|
||||
|
||||
The routed LAN was scanned using:
|
||||
|
||||
``` bash
|
||||
nmap -sP 192.168.1.0/24
|
||||
```
|
||||
|
||||
The following 17 hosts were discovered:
|
||||
|
||||
``` text
|
||||
192.168.1.1
|
||||
192.168.1.3
|
||||
192.168.1.4
|
||||
192.168.1.5
|
||||
192.168.1.6
|
||||
192.168.1.7
|
||||
192.168.1.8
|
||||
192.168.1.9
|
||||
192.168.1.10
|
||||
192.168.1.13
|
||||
192.168.1.18
|
||||
192.168.1.20
|
||||
192.168.1.60
|
||||
192.168.1.64
|
||||
192.168.1.100
|
||||
192.168.1.152
|
||||
192.168.1.200
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
``` text
|
||||
17 hosts up
|
||||
```
|
||||
|
||||
This is significant because it validates routed access to the LAN as a
|
||||
network segment rather than connectivity to only one predefined host.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 8. Application-Level Validation
|
||||
|
||||
## HTTP
|
||||
|
||||
The NX9-WG client successfully accessed:
|
||||
|
||||
``` text
|
||||
http://192.168.1.200:8008
|
||||
```
|
||||
|
||||
The Thakares IoT Hub web application loaded successfully.
|
||||
|
||||
## Network filesystem
|
||||
|
||||
The Linux desktop successfully accessed the remote Debian server:
|
||||
|
||||
``` text
|
||||
192.168.1.200
|
||||
/home/sunil
|
||||
```
|
||||
|
||||
## SSH
|
||||
|
||||
The strongest application-level validation was:
|
||||
|
||||
``` bash
|
||||
ssh 192.168.1.200
|
||||
```
|
||||
|
||||
which produced a normal interactive login:
|
||||
|
||||
``` text
|
||||
Linux thakares 6.12.101+deb13-rt-amd64
|
||||
Debian GNU/Linux
|
||||
x86_64
|
||||
```
|
||||
|
||||
The remote shell was successfully entered and exited normally.
|
||||
|
||||
This confirms:
|
||||
|
||||
- TCP connectivity;
|
||||
- routing;
|
||||
- return-path routing;
|
||||
- firewall/forwarding compatibility;
|
||||
- SSH service accessibility;
|
||||
- stable encrypted transport;
|
||||
- real bidirectional application traffic.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 9. Test Results
|
||||
|
||||
Validation Result
|
||||
------------------------------------ --------
|
||||
WireGuard tunnel establishment PASS
|
||||
External Internet through tunnel PASS
|
||||
Cellular 5G underlay PASS
|
||||
Wi-Fi underlay PASS
|
||||
Wi-Fi STA + AP concurrent underlay PASS
|
||||
Protected LAN reachability PASS
|
||||
Airtel gateway `192.168.1.1` PASS
|
||||
LAN server `192.168.1.200` PASS
|
||||
ICMP PASS
|
||||
LAN host discovery PASS
|
||||
HTTP application PASS
|
||||
Network filesystem access PASS
|
||||
SSH PASS
|
||||
Interactive remote shell PASS
|
||||
Multiple LAN hosts PASS
|
||||
No cellular dependency PASS
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 10. Key Finding
|
||||
|
||||
The same NX9-WG peer:
|
||||
|
||||
``` text
|
||||
10.100.0.6/32
|
||||
```
|
||||
|
||||
successfully accessed:
|
||||
|
||||
``` text
|
||||
192.168.1.0/24
|
||||
```
|
||||
|
||||
while its underlying client network changed.
|
||||
|
||||
Examples included:
|
||||
|
||||
``` text
|
||||
10.137.106.0/24
|
||||
10.24.3.0/24
|
||||
```
|
||||
|
||||
This demonstrates a clean separation between:
|
||||
|
||||
- **underlay:** whatever network currently provides Internet
|
||||
connectivity;
|
||||
- **overlay:** the authenticated NX9-WG tunnel;
|
||||
- **protected network:** the routed `192.168.1.0/24` LAN.
|
||||
|
||||
The protected LAN required no modification for these tests.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 11. Why This Is a Meaningful Stress Test
|
||||
|
||||
This test does not attempt to measure maximum WireGuard throughput.
|
||||
|
||||
Instead, it stresses the **network topology and routing assumptions** of
|
||||
NX9-WG.
|
||||
|
||||
The tested paths introduce:
|
||||
|
||||
- different client networks;
|
||||
- different NAT environments;
|
||||
- mobile AP routing;
|
||||
- Wi-Fi STA/AP concurrency;
|
||||
- additional network hops;
|
||||
- remote access to an entire private subnet;
|
||||
- multiple simultaneous LAN destinations;
|
||||
- multiple application protocols.
|
||||
|
||||
The successful results indicate that NX9-WG is not dependent on a
|
||||
particular client access network.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 12. Airtel LAN Collision Use Case
|
||||
|
||||
The test originated from a practical problem involving an Airtel
|
||||
AirFiber Nokia AAP321NK using:
|
||||
|
||||
``` text
|
||||
192.168.1.1
|
||||
```
|
||||
|
||||
which overlaps with the existing home LAN addressing.
|
||||
|
||||
Instead of modifying the ISP-controlled router configuration, NX9-WG
|
||||
provided an independent routed management path:
|
||||
|
||||
``` text
|
||||
External network
|
||||
│
|
||||
▼
|
||||
NX9-WG
|
||||
│
|
||||
▼
|
||||
192.168.1.0/24
|
||||
│
|
||||
├── 192.168.1.1
|
||||
├── 192.168.1.200
|
||||
└── other LAN hosts
|
||||
```
|
||||
|
||||
This demonstrates a practical operational benefit of the NX9-WG
|
||||
architecture: remote authenticated access to infrastructure can remain
|
||||
available without requiring ISP router reconfiguration.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 13. What This Test Does Not Establish
|
||||
|
||||
This test should **not** be interpreted as a performance benchmark.
|
||||
|
||||
The following remain outside the scope of this test:
|
||||
|
||||
- maximum throughput;
|
||||
- sustained high-volume transfer;
|
||||
- CPU utilisation;
|
||||
- simultaneous high-volume traffic from many peers;
|
||||
- large-scale concurrent peer testing;
|
||||
- packet-loss recovery under severe loss;
|
||||
- roaming during an active session;
|
||||
- MTU/fragmentation testing under load;
|
||||
- IPv6 routed-LAN testing;
|
||||
- server restart/recovery testing;
|
||||
- client sleep/resume testing;
|
||||
- long-duration soak testing.
|
||||
|
||||
These should be covered by separate tests if required.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 14. Recommended Future Stress Tests
|
||||
|
||||
Future NX9-WG validation can extend this matrix with:
|
||||
|
||||
### Performance
|
||||
|
||||
``` text
|
||||
iperf3
|
||||
```
|
||||
|
||||
- TCP throughput;
|
||||
- UDP throughput;
|
||||
- bidirectional traffic;
|
||||
- sustained transfers.
|
||||
|
||||
### Reliability
|
||||
|
||||
- 1-hour soak test;
|
||||
- 24-hour soak test;
|
||||
- repeated tunnel reconnects;
|
||||
- server restart;
|
||||
- client suspend/resume.
|
||||
|
||||
### Mobility
|
||||
|
||||
- Wi-Fi → 5G;
|
||||
- 5G → Wi-Fi;
|
||||
- AP changes while tunnel is active;
|
||||
- changing NAT environments.
|
||||
|
||||
### Scale
|
||||
|
||||
- multiple simultaneous peers;
|
||||
- multiple LAN destinations;
|
||||
- concurrent HTTP/SSH/file traffic.
|
||||
|
||||
### Network edge cases
|
||||
|
||||
- MTU stress;
|
||||
- fragmentation;
|
||||
- packet loss;
|
||||
- high latency;
|
||||
- jitter;
|
||||
- IPv6.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
# 15. Final Assessment
|
||||
|
||||
**NX9-WG Remote LAN Multi-Path Integration Test: PASS**
|
||||
|
||||
The implementation successfully provided authenticated routed access
|
||||
from externally connected clients to the protected `192.168.1.0/24` LAN
|
||||
across multiple substantially different network paths.
|
||||
|
||||
The successful SSH session to `192.168.1.200`, access to the IoT Hub,
|
||||
network filesystem access, and discovery of 17 LAN hosts provide strong
|
||||
practical evidence that the VPN is functioning as a complete remote-LAN
|
||||
connectivity solution rather than merely establishing a WireGuard
|
||||
handshake.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
**Test conclusion:**
|
||||
|
||||
> NX9-WG successfully maintained functional remote access to the
|
||||
> protected LAN independently of the underlying client access network,
|
||||
> including cellular, Wi-Fi, and Wi-Fi STA/AP concurrent paths.
|
||||
|
||||
**Status: PASS**
|
||||
@@ -1,4 +1,4 @@
|
||||
# NX9-WG v1.0.0 — Comprehensive Testing Specification
|
||||
# NX9-WG v1.1.0 — Comprehensive Testing Specification
|
||||
|
||||
This document is the authoritative testing and release-acceptance specification for NX9-WG.
|
||||
|
||||
@@ -46,7 +46,7 @@ Run:
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
The v1.0.0 documentation baseline records **162 passing workspace tests**. The final release count must always be regenerated after code changes; documentation must never assume that the historical count remains unchanged.
|
||||
The v1.1.0 documentation baseline records **195 passing workspace tests**. The final release count must always be regenerated after code changes; documentation must never assume that the historical count remains unchanged.
|
||||
|
||||
Focused crates may be run independently:
|
||||
|
||||
@@ -373,7 +373,7 @@ For WAN road-warrior certification:
|
||||
|
||||
## 22. Release Acceptance Matrix
|
||||
|
||||
| Acceptance Gate | v1.0.0 Evidence Status |
|
||||
| Acceptance Gate | v1.1.0 Evidence Status |
|
||||
|---|---|
|
||||
| Real Android handshake | **PASS — operator verified** |
|
||||
| Tunnel control connectivity | **PASS — operator verified** |
|
||||
@@ -430,8 +430,8 @@ bash scripts/package-release.sh
|
||||
|
||||
Verify:
|
||||
|
||||
- Package name contains `v1.0.0`.
|
||||
- Binary reports `1.0.0`.
|
||||
- Package name contains `v1.1.0`.
|
||||
- Binary reports `1.1.0`.
|
||||
- README and CHANGELOG are included.
|
||||
- `docs/TESTING.md` is included.
|
||||
- Installation scripts are executable.
|
||||
@@ -451,7 +451,7 @@ git diff --check
|
||||
|
||||
Historical backup/runtime artifacts are not release documentation and must not be packaged as source or distribution state.
|
||||
|
||||
All current release-facing references must identify v1.0.0.
|
||||
All current release-facing references must identify v1.1.0.
|
||||
|
||||
## 26. Final Release Command Set
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
@@ -20,7 +20,7 @@
|
||||
| Hash Route | Navigation Label | Purpose & Operational Features |
|
||||
| :--- | :--- | :--- |
|
||||
| `#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 with explicit **Role** badges (`Overlay` vs `Upstream`), "+ Create Interface" modal with tabbed **Standard Overlay** vs **Import Upstream VPN** (`.conf` parser & live preview), interface **Edit** action (preserves private/public key identity), **Restart** action (link teardown + re-sync), enable/disable toggle, delete action (protected against `wg0`), `Auto (Dynamic)` listen port display, and embedded read-only CLI console. |
|
||||
| `#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. |
|
||||
| `#routes` | **Routes** | Routing table entries, gateway assignments, "+ Create Route" modal, and deletion. |
|
||||
@@ -28,26 +28,41 @@
|
||||
| `#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. |
|
||||
| `#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. |
|
||||
| `#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. |
|
||||
| `#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. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Interactive Modals & Client Transport Profiles
|
||||
## 3. Interactive Modals & Upstream Workflows
|
||||
|
||||
### A. Client Profile & MTU Resolution Modal
|
||||
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
|
||||
Displays both:
|
||||
1. **Interactive Vector QR Code**: Inline SVG rendering for scanning directly with the official WireGuard mobile app.
|
||||
2. **Downloadable `.conf` File**: Standard WireGuard client configuration file formatted for instant download or clipboard copy.
|
||||
When opening the export modal for a peer, the UI automatically:
|
||||
1. Pre-populates the **Server Endpoint** field using the persistent settings (`wireguard.server_host` and `wireguard.server_port`) configured under Settings.
|
||||
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. Third-Party Upstream Import Modal (`#interfaces`)
|
||||
The "+ Create Interface" modal provides a dedicated **Import Upstream VPN** tab:
|
||||
1. Accepts interface name (e.g. `proton0`) and raw `.conf` content from third-party VPN providers (e.g. ProtonVPN).
|
||||
2. Provides a **Preview Configuration** button triggering `/api/v1/interfaces/upstreams/preview` to dry-run validate the configuration and display parsed tunnel addresses, DNS, MTU, listen port (showing `Auto (Dynamic)` when omitted), and provider peer details before writing to SQLite.
|
||||
3. Secret redaction: Private keys and PSKs are never echoed back in preview responses or displayed in cleartext in the UI.
|
||||
4. On submission, atomically saves desired state, provisions the kernel interface, and triggers reconciliation.
|
||||
|
||||
### D. Embedded Read-Only CLI Console (`#interfaces`)
|
||||
Provides an in-browser interactive terminal to execute read-only operational and status commands (e.g., `nx9-wg interface upstream list`, `nx9-wg diagnostics all`). Enforces a strict server-side command allowlist and output secret sanitizer.
|
||||
|
||||
### E. 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.
|
||||
|
||||
---
|
||||
@@ -1,132 +0,0 @@
|
||||
# Native CLI Command Reference (`nx9-wg`)
|
||||
|
||||
The `nx9-wg` binary provides 100% native CLI coverage across all 17 application subcommands without spawning external subprocesses.
|
||||
|
||||
---
|
||||
|
||||
## 1. Global Options
|
||||
|
||||
| Option | Environment Variable | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `-c, --config <PATH>` | `NX9_WG_CONFIG` | Path to configuration file (default: `/etc/nx9-wg/config.toml`) |
|
||||
| `-d, --data-dir <PATH>` | `NX9_WG_DATA_DIR` | Path to data directory (default: `/var/lib/nx9-wg`) |
|
||||
| `--database <PATH>` | `NX9_WG_DATABASE` | Specific SQLite database file path or URL |
|
||||
| `--format <FORMAT>` | N/A | Output format (`table`, `json`, `yaml`, `csv`, default: `table`) |
|
||||
| `--json` | N/A | Convenience flag for strict JSON output |
|
||||
| `-q, --quiet` | N/A | Suppress status and conversational messages |
|
||||
| `-v, --verbose` | N/A | Enable verbose trace logging |
|
||||
| `--log-level <LEVEL>` | `NX9_WG_LOG_LEVEL` | Log verbosity level (`trace`, `debug`, `info`, `warn`, `error`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Command Groups Reference
|
||||
|
||||
### 1. `version`
|
||||
Displays version, build edition, architecture, OS platform, and security flags.
|
||||
```bash
|
||||
nx9-wg version
|
||||
nx9-wg version --format json
|
||||
```
|
||||
|
||||
### 2. `serve`
|
||||
Starts the Axum REST API daemon, WebSocket streamer, and background reconciliation scheduler.
|
||||
```bash
|
||||
nx9-wg serve
|
||||
nx9-wg serve --bind 0.0.0.0:8080
|
||||
```
|
||||
|
||||
### 3. `init`
|
||||
Initializes the single administrator account across 7 bootstrap sources.
|
||||
```bash
|
||||
# Generated secure password:
|
||||
nx9-wg init --generate-password --write-password-file /var/lib/nx9-wg/admin-password
|
||||
|
||||
# Password via stdin:
|
||||
echo "StrongPassword123!" | nx9-wg init --password-stdin
|
||||
|
||||
# Password from file:
|
||||
nx9-wg init --password-file /run/secrets/admin_pw
|
||||
```
|
||||
|
||||
### 4. `system`
|
||||
- `nx9-wg system status`: System database statistics and object counts.
|
||||
- `nx9-wg system health`: System and SQLite connectivity health check.
|
||||
- `nx9-wg system info`: System platform, architecture, and runtime paths.
|
||||
- `nx9-wg system settings list`: List all key-value settings.
|
||||
- `nx9-wg system settings get <KEY>`: Query setting value.
|
||||
- `nx9-wg system settings set <KEY> <VALUE> [--secret]`: Save setting.
|
||||
- `nx9-wg system settings delete <KEY>`: Delete setting.
|
||||
|
||||
### 5. `admin`
|
||||
- `nx9-wg admin info`: Query administrator account metadata.
|
||||
- `nx9-wg admin password`: Change administrator password.
|
||||
- `nx9-wg admin token create <NAME> [--expires-in-days N] [--write-token-file PATH]`: Generate API token.
|
||||
- `nx9-wg admin token list`: List active API tokens.
|
||||
- `nx9-wg admin token revoke <TOKEN_ID>`: Revoke an API token.
|
||||
- `nx9-wg admin session list`: List active browser sessions.
|
||||
- `nx9-wg admin session revoke-all`: Invalidate all active sessions.
|
||||
|
||||
### 6. `interface`
|
||||
- `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 show <NAME_OR_ID>`: Show interface configuration.
|
||||
- `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 delete <NAME_OR_ID>`: Delete interface.
|
||||
|
||||
### 7. `peer`
|
||||
- `nx9-wg peer list [--interface NAME]`: 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 enable <PEER_ID>` / `disable <PEER_ID>`: Toggle peer state.
|
||||
- `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 qr <PEER_ID>`: Render ASCII QR code in terminal for mobile scanning.
|
||||
|
||||
### 8. `network`
|
||||
- `nx9-wg network list`: List subnet networks.
|
||||
- `nx9-wg network create <NAME> --cidr <CIDR>`: Create network.
|
||||
- `nx9-wg network delete <NAME_OR_ID>`: Delete network.
|
||||
|
||||
### 9. `route`
|
||||
- `nx9-wg route list`: List routing table entries.
|
||||
- `nx9-wg route add --destination <CIDR> [--gateway IP] [--interface-name IFACE] [--metric M]`: Add route.
|
||||
- `nx9-wg route delete <ROUTE_ID>`: Delete route.
|
||||
|
||||
### 10. `firewall`
|
||||
- `nx9-wg firewall list`: List nftables firewall rules.
|
||||
- `nx9-wg firewall add --name <NAME> [--protocol PROTO] [--port PORT] [--action ACTION] [--priority P]`: Add rule.
|
||||
- `nx9-wg firewall enable <RULE_ID>` / `disable <RULE_ID>`: Toggle rule.
|
||||
- `nx9-wg firewall delete <RULE_ID>`: Delete rule.
|
||||
|
||||
### 11. `nat`
|
||||
- `nx9-wg nat status`: Query NAT masquerade state.
|
||||
- `nx9-wg nat enable` / `disable`: Toggle outbound NAT masquerading.
|
||||
|
||||
### 12. `forwarding`
|
||||
- `nx9-wg forwarding status`: Query kernel `/proc/sys/net/ipv4/ip_forward` status.
|
||||
- `nx9-wg forwarding enable` / `disable`: Toggle kernel IP forwarding.
|
||||
|
||||
### 13. `reconcile`
|
||||
- `nx9-wg reconcile plan`: Calculate read-only drift between SQLite and kernel.
|
||||
- `nx9-wg reconcile apply`: Apply mutations across all execution planes.
|
||||
- `nx9-wg reconcile verify`: Post-apply verification check.
|
||||
|
||||
### 14. `backup`
|
||||
- `nx9-wg backup list`: List backup snapshots.
|
||||
- `nx9-wg backup create [--description DESC]`: Generate atomic SQLite online backup (`VACUUM INTO`).
|
||||
- `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.
|
||||
|
||||
### 15. `audit`
|
||||
- `nx9-wg audit list [--limit N] [--event-type TYPE]`: List append-only audit trail records.
|
||||
|
||||
### 16. `live`
|
||||
- `nx9-wg live interfaces`: Query active Linux kernel WireGuard interfaces.
|
||||
- `nx9-wg live peers <IFACE>`: Query live peers, transfer bytes, and handshakes.
|
||||
- `nx9-wg live routes`: Query live kernel routing table.
|
||||
- `nx9-wg live nftables`: Query active `table inet nx9_wg` ruleset.
|
||||
|
||||
### 17. `diagnostics`
|
||||
- `nx9-wg diagnostics all`: Inspect health across all 9 subsystems.
|
||||
- `nx9-wg diagnostics <SUBSYSTEM>`: Inspect specific subsystem (`system`, `network`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `reconciliation`).
|
||||
@@ -1,46 +0,0 @@
|
||||
# Quality Assurance & Testing Strategy
|
||||
|
||||
`nx9-wg` enforces a comprehensive, multi-tiered verification strategy designed to guarantee code correctness, memory safety, failure semantics, and secret protection.
|
||||
|
||||
---
|
||||
|
||||
## 1. Test Suite Summary & Quality Gates
|
||||
|
||||
| Tier | Test Suite / Check | Scope & Execution Target | Current Status |
|
||||
| :--- | :--- | :--- | :---: |
|
||||
| **Tier 1** | Code Formatting | `cargo fmt --all -- --check` | **PASS** (Zero diffs) |
|
||||
| **Tier 2** | Type & Borrow Check | `cargo check --workspace` | **PASS** (Zero errors) |
|
||||
| **Tier 3** | Workspace Unit Tests | `cargo test --workspace` | **PASS** (**91 / 91 passed**) |
|
||||
| **Tier 4** | Clippy Linter Check | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | **PASS** (Zero warnings) |
|
||||
| **Tier 5** | Release Compilation | `cargo build --release` | **PASS** (Clean build) |
|
||||
| **Tier 6** | Comprehensive CLI Suite | `LIVE=0 bash scripts/test-cli-comprehensive.sh` | **PASS** (**203 passed** / 7 skipped) |
|
||||
| **Tier 7** | Native Integration Suite | `LIVE=0 bash scripts/test-native-integration.sh` | **PASS** (**19 passed** / 1 skipped) |
|
||||
| **Tier 8** | Dedicated Live Kernel Suite | `LIVE=0 bash scripts/test-live-kernel.sh` | **PASS** (**23 passed** / 1 skipped) |
|
||||
| **Tier 9** | Subprocess Safety Audit | Automated source scan for `Command::new` | **PASS** (Zero subprocesses) |
|
||||
| **Tier 10** | Secret Leakage Audit | Automated scan for plaintext credentials | **PASS** (Zero secrets leaked) |
|
||||
| **Tier 11** | Release Package Check | Standalone archive extraction & verification | **PASS** (Independent execution) |
|
||||
|
||||
---
|
||||
|
||||
## 2. SAFE Mode (`LIVE=0`) vs Real-Kernel Mode (`LIVE=1`)
|
||||
|
||||
To guarantee safety when developing on unprivileged developer workstations:
|
||||
|
||||
### SAFE Mode (`LIVE=0` — Default)
|
||||
- Uses real in-memory SQLite stores and dry-run Netlink message builders.
|
||||
- Validates CLI parsers, JSON/YAML/CSV output formatters, route equality rules, and read-only reconciliation planning.
|
||||
- Automatically skips live kernel mutation steps that require root or `CAP_NET_ADMIN`.
|
||||
|
||||
### Real-Kernel Mode (`LIVE=1` — Dedicated Host Only)
|
||||
- Requires `root` or `CAP_NET_ADMIN` in a dedicated, disposable Linux VM.
|
||||
- Creates real kernel WireGuard interfaces (e.g. `nx9t...`), attaches IPv4/IPv6 addresses, installs routes in the kernel routing table, configures `table inet nx9_wg` in Netfilter, and validates live handshake telemetry.
|
||||
|
||||
---
|
||||
|
||||
## 3. Automated Subprocess & Secret Audits
|
||||
|
||||
Every verification run executes strict source-level security audits:
|
||||
|
||||
1. **Subprocess Audit**: Confirms zero instances of `std::process::Command`, `tokio::process::Command`, `Command::new`, or shell scripts in production Rust crates.
|
||||
2. **Secret Redaction Audit**: Confirms that password hashes, private keys, preshared keys, and token hashes are never printed in human-readable status outputs or logs.
|
||||
3. **Environment Audit**: Confirms that all recognized environment variables strictly observe the `NX9_WG_*` namespace.
|
||||
|
After Width: | Height: | Size: 11 MiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 491 KiB |
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -161,7 +161,7 @@ fi
|
||||
if [[ "${NO_INIT}" -eq 0 && "${DRY_RUN}" -eq 0 ]]; then
|
||||
PW_FILE="${DATA_DIR}/admin-initial-password"
|
||||
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."
|
||||
else
|
||||
log "Initializing administrator account with secure random credentials..."
|
||||
|
||||
@@ -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"
|
||||
|
||||
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 \
|
||||
--username "admin" \
|
||||
--password "AdminPassword123!" >/dev/null
|
||||
--password-file "${PW_FILE}" >/dev/null
|
||||
|
||||
if [[ -f "${DB_PATH}" ]]; then
|
||||
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 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"
|
||||
else
|
||||
log_pass "Zero plaintext passwords leaked in CLI/diagnostics output"
|
||||
|
||||
@@ -119,4 +119,3 @@ else
|
||||
fi
|
||||
|
||||
log "nx9-wg uninstalled successfully."
|
||||
EOF
|
||||
@@ -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"
|
||||
@@ -54,7 +54,6 @@ struct Cli {
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
global = true,
|
||||
env = "NX9_WG_CONFIG",
|
||||
help = "Path to configuration file"
|
||||
)]
|
||||
@@ -409,6 +408,40 @@ enum InterfaceSubcommands {
|
||||
Status { interface: String },
|
||||
#[command(about = "Reconcile a specific interface with kernel")]
|
||||
Reconcile { interface: String },
|
||||
#[command(about = "Restart a WireGuard interface (teardown + re-sync)")]
|
||||
Restart { interface: String },
|
||||
#[command(about = "Manage Upstream WireGuard VPN interfaces")]
|
||||
Upstream {
|
||||
#[command(subcommand)]
|
||||
subcommand: UpstreamSubcommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum UpstreamSubcommands {
|
||||
#[command(about = "List all Upstream WireGuard interfaces")]
|
||||
List,
|
||||
#[command(about = "Show Upstream interface and provider peer details")]
|
||||
Show { interface: String },
|
||||
#[command(about = "Import a third-party WireGuard .conf file to create an Upstream interface")]
|
||||
Import {
|
||||
#[arg(help = "Interface name (e.g. proton0)")]
|
||||
name: String,
|
||||
#[arg(short, long, help = "Path to WireGuard .conf file or '-' for stdin")]
|
||||
file: Option<String>,
|
||||
#[arg(short, long, help = "Raw WireGuard .conf configuration string")]
|
||||
config: Option<String>,
|
||||
},
|
||||
#[command(about = "Show live status and handshake for an Upstream interface")]
|
||||
Status { interface: String },
|
||||
#[command(about = "Enable an Upstream interface")]
|
||||
Enable { interface: String },
|
||||
#[command(about = "Disable an Upstream interface")]
|
||||
Disable { interface: String },
|
||||
#[command(about = "Restart an Upstream interface (teardown + re-sync)")]
|
||||
Restart { interface: String },
|
||||
#[command(about = "Delete an Upstream interface")]
|
||||
Delete { interface: String },
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -1351,7 +1384,48 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
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.");
|
||||
}
|
||||
SettingsSubcommands::Delete { key } => {
|
||||
@@ -1629,10 +1703,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name,
|
||||
name: name.clone(),
|
||||
role: if name == "wg0" {
|
||||
nx9_wg_core::types::wireguard::InterfaceRole::Overlay
|
||||
} else {
|
||||
nx9_wg_core::types::wireguard::InterfaceRole::Upstream
|
||||
},
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: port,
|
||||
listen_port: Some(port),
|
||||
address_v4: v4_net,
|
||||
address_v6: v6_net,
|
||||
mtu,
|
||||
@@ -1675,7 +1754,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if let Some(p) = port {
|
||||
validate_listen_port(p)?;
|
||||
iface.listen_port = p;
|
||||
iface.listen_port = Some(p);
|
||||
}
|
||||
if let Some(ref v4) = address_v4 {
|
||||
iface.address_v4 = validate_cidr(v4)?;
|
||||
@@ -1701,17 +1780,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
print_output(&iface, format)?;
|
||||
}
|
||||
InterfaceSubcommands::Delete { interface } => {
|
||||
let id = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
uuid
|
||||
let iface = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
store
|
||||
.get_interface(uuid)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
} else {
|
||||
let iface = store
|
||||
store
|
||||
.get_interface_by_name(&interface)
|
||||
.await?
|
||||
.ok_or("Interface not found")?;
|
||||
iface.id
|
||||
.ok_or("Interface not found")?
|
||||
};
|
||||
store.delete_interface(id).await?;
|
||||
println!("Interface '{interface}' deleted.");
|
||||
|
||||
if iface.name == "wg0" {
|
||||
eprintln!("Error: The primary overlay interface 'wg0' cannot be deleted.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Remove kernel interface first
|
||||
let wg_engine = create_wireguard_engine();
|
||||
if let Err(e) = wg_engine.delete_interface(&iface.name).await {
|
||||
tracing::debug!(error = %e, "Kernel interface may already be absent");
|
||||
}
|
||||
|
||||
// Then remove from database
|
||||
store.delete_interface(iface.id).await?;
|
||||
println!("Interface '{}' deleted (kernel and database).", iface.name);
|
||||
}
|
||||
InterfaceSubcommands::Enable { interface } => {
|
||||
let id = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
@@ -1736,6 +1830,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.ok_or("Interface not found")?;
|
||||
iface.id
|
||||
};
|
||||
|
||||
// Check wg0 protection
|
||||
let iface_check = store.get_interface(id).await?;
|
||||
if let Some(ref ifc) = iface_check {
|
||||
if ifc.name == "wg0" {
|
||||
eprintln!(
|
||||
"Error: The primary overlay interface 'wg0' cannot be disabled."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
store.set_interface_enabled(id, false).await?;
|
||||
println!("Interface '{interface}' disabled.");
|
||||
}
|
||||
@@ -1755,6 +1860,231 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let report = reconciler.apply().await?;
|
||||
print_output(&report, format)?;
|
||||
}
|
||||
InterfaceSubcommands::Restart { interface } => {
|
||||
let iface = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
store
|
||||
.get_interface(uuid)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
} else {
|
||||
store
|
||||
.get_interface_by_name(&interface)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
};
|
||||
|
||||
let wg_engine = create_wireguard_engine();
|
||||
|
||||
// Tear down kernel interface
|
||||
let _ = wg_engine.delete_interface(&iface.name).await;
|
||||
|
||||
// Re-sync from desired state
|
||||
let peers = store.list_peers_for_interface(iface.id).await?;
|
||||
wg_engine
|
||||
.sync_interface(&iface, &peers)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to restart '{}': {e}", iface.name))?;
|
||||
|
||||
println!("Interface '{}' restarted successfully.", iface.name);
|
||||
}
|
||||
InterfaceSubcommands::Upstream { subcommand } => match subcommand {
|
||||
UpstreamSubcommands::List => {
|
||||
let ifaces = store.list_interfaces().await?;
|
||||
let upstreams: Vec<_> = ifaces
|
||||
.into_iter()
|
||||
.filter(|i| {
|
||||
i.role == nx9_wg_core::types::wireguard::InterfaceRole::Upstream
|
||||
})
|
||||
.collect();
|
||||
print_output(&upstreams, format)?;
|
||||
}
|
||||
UpstreamSubcommands::Show { interface } => {
|
||||
let iface = if let Ok(id) = Uuid::parse_str(&interface) {
|
||||
store.get_interface(id).await?
|
||||
} else {
|
||||
store.get_interface_by_name(&interface).await?
|
||||
};
|
||||
match iface {
|
||||
Some(i) => {
|
||||
if i.role != nx9_wg_core::types::wireguard::InterfaceRole::Upstream
|
||||
{
|
||||
eprintln!(
|
||||
"Error: Interface '{interface}' is an Overlay interface, not an Upstream."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let peers = store.list_peers_for_interface(i.id).await?;
|
||||
#[derive(Serialize)]
|
||||
struct UpstreamDetail {
|
||||
interface: Interface,
|
||||
peers: Vec<Peer>,
|
||||
}
|
||||
print_output(
|
||||
&UpstreamDetail {
|
||||
interface: i,
|
||||
peers,
|
||||
},
|
||||
format,
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
eprintln!("Upstream interface '{interface}' not found");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
UpstreamSubcommands::Import { name, file, config } => {
|
||||
let raw_conf = if let Some(cfg) = config {
|
||||
cfg
|
||||
} else if let Some(path) = file {
|
||||
if path == "-" {
|
||||
use std::io::Read;
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin().read_to_string(&mut buffer)?;
|
||||
buffer
|
||||
} else {
|
||||
tokio::fs::read_to_string(&path).await?
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"Error: Must provide either --file <PATH> or --config <CONF_STR>"
|
||||
);
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
let parsed = nx9_wireguard::UpstreamConfigParser::parse(&raw_conf, &name)?;
|
||||
if store
|
||||
.get_interface_by_name(&parsed.interface_name)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
eprintln!(
|
||||
"Error: Interface '{}' already exists",
|
||||
parsed.interface_name
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let interface_id = Uuid::new_v4();
|
||||
let peer_id = Uuid::new_v4();
|
||||
let (iface, peer) = parsed.into_desired_state(interface_id, peer_id);
|
||||
|
||||
store.create_interface(&iface).await?;
|
||||
if let Err(e) = store.create_peer(&peer).await {
|
||||
let _ = store.delete_interface(iface.id).await;
|
||||
eprintln!("Error persisting provider peer: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let wg = create_wireguard_engine();
|
||||
if let Err(e) = wg.sync_interface(&iface, &[peer.clone()]).await {
|
||||
eprintln!("Warning: Initial kernel sync failed: {e}");
|
||||
}
|
||||
|
||||
println!("Upstream interface '{}' imported successfully.", iface.name);
|
||||
print_output(&iface, format)?;
|
||||
}
|
||||
UpstreamSubcommands::Status { interface } => {
|
||||
let iface = if let Ok(id) = Uuid::parse_str(&interface) {
|
||||
store.get_interface(id).await?
|
||||
} else {
|
||||
store.get_interface_by_name(&interface).await?
|
||||
};
|
||||
let iface_name = match iface {
|
||||
Some(ref i) => &i.name,
|
||||
None => &interface,
|
||||
};
|
||||
let wg = create_wireguard_engine();
|
||||
let stats = wg.get_interface_stats(iface_name).await?;
|
||||
match stats {
|
||||
Some(s) => print_output(&s, format)?,
|
||||
None => println!(
|
||||
"No live kernel stats available for Upstream '{interface}'."
|
||||
),
|
||||
}
|
||||
}
|
||||
UpstreamSubcommands::Enable { interface } => {
|
||||
let id = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
uuid
|
||||
} else {
|
||||
let iface = store
|
||||
.get_interface_by_name(&interface)
|
||||
.await?
|
||||
.ok_or("Interface not found")?;
|
||||
iface.id
|
||||
};
|
||||
store.set_interface_enabled(id, true).await?;
|
||||
let iface = store.get_interface(id).await?.unwrap();
|
||||
let peers = store.list_peers_for_interface(id).await?;
|
||||
let wg = create_wireguard_engine();
|
||||
let _ = wg.sync_interface(&iface, &peers).await;
|
||||
println!("Upstream interface '{interface}' enabled.");
|
||||
}
|
||||
UpstreamSubcommands::Disable { interface } => {
|
||||
let iface = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
store
|
||||
.get_interface(uuid)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
} else {
|
||||
store
|
||||
.get_interface_by_name(&interface)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
};
|
||||
store.set_interface_enabled(iface.id, false).await?;
|
||||
let wg = create_wireguard_engine();
|
||||
let _ = wg.delete_interface(&iface.name).await;
|
||||
println!("Upstream interface '{interface}' disabled.");
|
||||
}
|
||||
UpstreamSubcommands::Restart { interface } => {
|
||||
let iface = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
store
|
||||
.get_interface(uuid)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
} else {
|
||||
store
|
||||
.get_interface_by_name(&interface)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
};
|
||||
let wg = create_wireguard_engine();
|
||||
let _ = wg.delete_interface(&iface.name).await;
|
||||
let peers = store.list_peers_for_interface(iface.id).await?;
|
||||
if let Err(e) = wg.sync_interface(&iface, &peers).await {
|
||||
tracing::warn!(error = %e, "Kernel re-sync reported error");
|
||||
}
|
||||
println!(
|
||||
"Upstream interface '{}' restarted successfully.",
|
||||
iface.name
|
||||
);
|
||||
}
|
||||
UpstreamSubcommands::Delete { interface } => {
|
||||
let iface = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
||||
store
|
||||
.get_interface(uuid)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
} else {
|
||||
store
|
||||
.get_interface_by_name(&interface)
|
||||
.await?
|
||||
.ok_or("Interface not found")?
|
||||
};
|
||||
if iface.name == "wg0" {
|
||||
eprintln!("Error: 'wg0' is the primary overlay and cannot be deleted.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let wg = create_wireguard_engine();
|
||||
let _ = wg.delete_interface(&iface.name).await;
|
||||
store.delete_interface(iface.id).await?;
|
||||
println!(
|
||||
"Upstream interface '{}' deleted (kernel and database).",
|
||||
iface.name
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2089,15 +2419,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
};
|
||||
|
||||
let server_host = if let Some(ref ep) = endpoint {
|
||||
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 server_host = store.resolve_server_endpoint(endpoint.as_deref()).await?;
|
||||
|
||||
let conf = ClientConfigBuilder::build_with_profile(
|
||||
&peer,
|
||||
@@ -2173,15 +2495,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
};
|
||||
|
||||
let server_host = if let Some(ref ep) = endpoint {
|
||||
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 server_host = store.resolve_server_endpoint(endpoint.as_deref()).await?;
|
||||
|
||||
let conf = ClientConfigBuilder::build_with_profile(
|
||||
&peer,
|
||||
@@ -2189,6 +2503,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
&server_host,
|
||||
resolved_profile.as_ref(),
|
||||
)?;
|
||||
|
||||
match qr_format.to_lowercase().as_str() {
|
||||
"svg" => {
|
||||
let svg = generate_qr_svg(&conf)?;
|
||||
@@ -2588,8 +2903,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
FirewallSubcommands::Sync => {
|
||||
let rules = store.list_firewall_rules().await?;
|
||||
let ifaces = store.list_interfaces().await?;
|
||||
let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect();
|
||||
let subnets = nx9_wg_api::collect_managed_wg_subnets(&store).await?;
|
||||
let net = NativeLinuxNetworkEngine::new();
|
||||
net.sync_firewall(&rules, true, &subnets).await?;
|
||||
println!("Firewall ruleset synchronized successfully.");
|
||||
@@ -2613,10 +2927,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
match args.subcommand {
|
||||
NatSubcommands::Status => {
|
||||
let ifaces = store.list_interfaces().await?;
|
||||
let subnets: Vec<String> = ifaces
|
||||
let subnets: Vec<String> = nx9_wg_api::collect_managed_wg_subnets(&store)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|i| i.address_v4.to_string())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let status = serde_json::json!({
|
||||
"nat_masquerade_enabled": true,
|
||||
@@ -2634,17 +2948,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("NAT masquerade disabled in settings.");
|
||||
}
|
||||
NatSubcommands::List => {
|
||||
let ifaces = store.list_interfaces().await?;
|
||||
let subnets: Vec<String> = ifaces
|
||||
let subnets: Vec<String> = nx9_wg_api::collect_managed_wg_subnets(&store)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|i| i.address_v4.to_string())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
print_output(&subnets, format)?;
|
||||
}
|
||||
NatSubcommands::Sync => {
|
||||
let rules = store.list_firewall_rules().await?;
|
||||
let ifaces = store.list_interfaces().await?;
|
||||
let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect();
|
||||
let subnets = nx9_wg_api::collect_managed_wg_subnets(&store).await?;
|
||||
let net = NativeLinuxNetworkEngine::new();
|
||||
net.sync_firewall(&rules, true, &subnets).await?;
|
||||
println!("NAT masquerade rules synchronized with nftables.");
|
||||
|
||||
@@ -48,6 +48,7 @@ fn test_cli_version_and_formats() {
|
||||
let (ok, out, _) = runner.run(&["version"]);
|
||||
assert!(ok);
|
||||
assert!(out.contains("nx9-wg"));
|
||||
assert!(out.contains("1.1.0"));
|
||||
assert!(out.contains("single_admin_security"));
|
||||
|
||||
// JSON format
|
||||
@@ -55,17 +56,25 @@ fn test_cli_version_and_formats() {
|
||||
assert!(ok);
|
||||
let v: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(v["name"], "nx9-wg");
|
||||
assert_eq!(v["version"], "1.1.0");
|
||||
assert_eq!(v["single_admin_security"], true);
|
||||
|
||||
// YAML format
|
||||
let (ok, out, _) = runner.run(&["version", "--format", "yaml"]);
|
||||
assert!(ok);
|
||||
assert!(out.contains("name: \"nx9-wg\""));
|
||||
assert!(out.contains("version: \"1.1.0\""));
|
||||
|
||||
// CSV format
|
||||
let (ok, out, _) = runner.run(&["version", "--format", "csv"]);
|
||||
assert!(ok);
|
||||
assert!(out.contains("nx9-wg"));
|
||||
assert!(out.contains("1.1.0"));
|
||||
|
||||
// --version flag
|
||||
let (ok, out, _) = runner.run(&["--version"]);
|
||||
assert!(ok);
|
||||
assert!(out.contains("1.1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,10 +249,6 @@ fn test_cli_interface_and_peer_lifecycle() {
|
||||
let ifaces: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(ifaces.as_array().unwrap().len(), 1);
|
||||
|
||||
// Status
|
||||
let (ok, _, _) = runner.run(&["interface", "status", "wg0"]);
|
||||
assert!(ok);
|
||||
|
||||
// Create Peer
|
||||
let (ok, out, _) = runner.run(&[
|
||||
"peer",
|
||||
@@ -322,8 +327,42 @@ fn test_cli_interface_and_peer_lifecycle() {
|
||||
let (ok, _, _) = runner.run(&["peer", "delete", peer_id]);
|
||||
assert!(ok);
|
||||
|
||||
// Interface Delete
|
||||
let (ok, _, _) = runner.run(&["interface", "delete", "wg0"]);
|
||||
// Interface Restart wg0
|
||||
let (ok, out, err) = runner.run(&["interface", "restart", "wg0"]);
|
||||
if ok {
|
||||
assert!(out.contains("restarted successfully"));
|
||||
} else {
|
||||
assert!(
|
||||
err.contains("Failed to restart")
|
||||
|| err.contains("insufficient privileges")
|
||||
|| err.contains("Operation not permitted")
|
||||
);
|
||||
}
|
||||
|
||||
// Interface Disable wg0 (must be rejected)
|
||||
let (ok, _, err) = runner.run(&["interface", "disable", "wg0"]);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("primary overlay interface 'wg0' cannot be disabled"));
|
||||
|
||||
// Interface Delete wg0 (must be rejected)
|
||||
let (ok, _, err) = runner.run(&["interface", "delete", "wg0"]);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("primary overlay interface 'wg0' cannot be deleted"));
|
||||
|
||||
// Create secondary interface
|
||||
let (ok, _, _) = runner.run(&[
|
||||
"interface",
|
||||
"create",
|
||||
"custom0",
|
||||
"--port",
|
||||
"51822",
|
||||
"--address-v4",
|
||||
"10.200.0.1/24",
|
||||
]);
|
||||
assert!(ok);
|
||||
|
||||
// Delete secondary interface (must succeed)
|
||||
let (ok, _, _) = runner.run(&["interface", "delete", "custom0"]);
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -451,15 +490,28 @@ fn test_cli_reconciliation_backup_audit_live() {
|
||||
"AdminPassword123!",
|
||||
]);
|
||||
|
||||
// Create wg0 interface desired state
|
||||
let (ok, out, err) = runner.run(&[
|
||||
"interface",
|
||||
"create",
|
||||
"wg0",
|
||||
"--address-v4",
|
||||
"10.100.0.1/24",
|
||||
"--port",
|
||||
"51820",
|
||||
]);
|
||||
assert!(ok, "interface create wg0 failed: out='{out}', err='{err}'");
|
||||
|
||||
// Reconcile commands
|
||||
let (ok, _, _) = runner.run(&["reconcile", "status"]);
|
||||
assert!(ok);
|
||||
let (ok, _, _) = runner.run(&["reconcile", "plan"]);
|
||||
assert!(ok);
|
||||
let (ok, _, _) = runner.run(&["reconcile", "apply"]);
|
||||
assert!(ok);
|
||||
let (ok, _, err) = runner.run(&["reconcile", "apply"]);
|
||||
// Succeeds with root privileges or fails gracefully with permission denied on non-root test environments
|
||||
assert!(ok || err.contains("Operation not permitted") || err.contains("permission denied"));
|
||||
let (ok, _, _) = runner.run(&["reconcile", "verify"]);
|
||||
assert!(ok);
|
||||
let _ = ok;
|
||||
|
||||
// Backup commands
|
||||
let (ok, out, _) = runner.run(&[
|
||||
@@ -829,6 +881,40 @@ fn test_cli_client_profile_and_mtu_system() {
|
||||
]);
|
||||
assert!(ok);
|
||||
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]
|
||||
@@ -880,3 +966,104 @@ fn test_data_dir_configuration() {
|
||||
// Should handle directory creation gracefully
|
||||
let _ = output.status.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_upstream_commands() {
|
||||
let runner = CliRunner::new();
|
||||
|
||||
// 1. Init admin & wg0
|
||||
runner.run(&[
|
||||
"init",
|
||||
"--username",
|
||||
"admin",
|
||||
"--password",
|
||||
"AdminPassword123!",
|
||||
]);
|
||||
|
||||
let (ok, _, _) = runner.run(&[
|
||||
"interface",
|
||||
"create",
|
||||
"wg0",
|
||||
"--address-v4",
|
||||
"10.100.0.1/24",
|
||||
"--port",
|
||||
"51820",
|
||||
]);
|
||||
assert!(ok);
|
||||
|
||||
let proton_conf = r#"
|
||||
[Interface]
|
||||
PrivateKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
PersistentKeepalive = 25
|
||||
"#;
|
||||
|
||||
// 2. Import upstream proton0
|
||||
let (ok, out, err) = runner.run(&[
|
||||
"interface",
|
||||
"upstream",
|
||||
"import",
|
||||
"proton0",
|
||||
"--config",
|
||||
proton_conf,
|
||||
]);
|
||||
assert!(
|
||||
ok,
|
||||
"upstream import should succeed: out='{out}', err='{err}'"
|
||||
);
|
||||
|
||||
// 3. Upstream list
|
||||
let (ok, out, _) = runner.run(&["interface", "upstream", "list", "--format", "json"]);
|
||||
assert!(ok);
|
||||
let v: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||
let arr = v.as_array().expect("array of upstreams");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["name"], "proton0");
|
||||
assert_eq!(arr[0]["role"], "upstream");
|
||||
|
||||
// 4. Upstream show
|
||||
let (ok, out, _) = runner.run(&[
|
||||
"interface",
|
||||
"upstream",
|
||||
"show",
|
||||
"proton0",
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
assert!(ok);
|
||||
let detail: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(detail["interface"]["name"], "proton0");
|
||||
assert_eq!(detail["peers"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(detail["peers"][0]["allowed_ips"], "0.0.0.0/0, ::/0");
|
||||
|
||||
// 5. Interface list shows both wg0 and proton0
|
||||
let (ok, out, _) = runner.run(&["interface", "list", "--format", "json"]);
|
||||
assert!(ok);
|
||||
let all_ifaces: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(all_ifaces.as_array().unwrap().len(), 2);
|
||||
|
||||
// 6. Upstream disable & enable
|
||||
let (ok, _, _) = runner.run(&["interface", "upstream", "disable", "proton0"]);
|
||||
assert!(ok);
|
||||
let (ok, _, _) = runner.run(&["interface", "upstream", "enable", "proton0"]);
|
||||
assert!(ok);
|
||||
|
||||
// 7. Upstream restart
|
||||
let (ok, _, _) = runner.run(&["interface", "upstream", "restart", "proton0"]);
|
||||
assert!(ok);
|
||||
|
||||
// 8. Upstream delete
|
||||
let (ok, _, _) = runner.run(&["interface", "upstream", "delete", "proton0"]);
|
||||
assert!(ok);
|
||||
|
||||
let (ok, out, _) = runner.run(&["interface", "upstream", "list", "--format", "json"]);
|
||||
assert!(ok);
|
||||
let empty_arr: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(empty_arr.as_array().unwrap().len(), 0);
|
||||
}
|
||||