release: NX9-WG v1.0.0
This commit is contained in:
1 parent
c8a9b7cde6
commit
4dfe42fe68
42 files changed
+4664
-311
No files matched your search
@@ -0,0 +1,64 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to **NX9-WG (`nx9-wg`)** are documented here.
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-08-18
|
||||||
|
|
||||||
|
NX9-WG 1.0.0 is the first production release of the native Linux WireGuard + network control plane.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Native Linux WireGuard lifecycle management through WireGuard Generic Netlink and RTNETLINK.
|
||||||
|
- Native IPv4/IPv6 address and route management without `wg`, `wg-quick`, `ip`, `iptables`, `nft`, `sysctl`, or shell orchestration from production Rust.
|
||||||
|
- Native nftables firewall/NAT execution scoped to the managed `table inet nx9_wg` table.
|
||||||
|
- SQLite authoritative desired-state storage with reconciliation and drift correction.
|
||||||
|
- Live WireGuard telemetry including learned peer endpoints, handshake timestamps, and RX/TX counters.
|
||||||
|
- Correct separation of client-side `AllowedIPs` from server-side WireGuard Cryptokey Routing `AllowedIPs`.
|
||||||
|
- Road-warrior server peer routing derived from assigned tunnel addresses (`/32` and `/128`) unless an explicit server-side override is configured.
|
||||||
|
- Persistent WireGuard server endpoint configuration for client configuration and QR exports.
|
||||||
|
- Interface editing through the WebUI with cryptographic identity preservation.
|
||||||
|
- WebUI peer lifecycle states: Connected, Awaiting Handshake, Disconnected, Disabled, Expired, and Revoked.
|
||||||
|
- Pure Rust client configuration and QR generation.
|
||||||
|
- CLI, REST API, WebSocket, embedded SPA, diagnostics, backup/restore, and reconciliation tooling.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Peer API responses now merge fresh kernel telemetry instead of relying solely on cached SQLite values.
|
||||||
|
- Handshake timestamps are serialized as explicit UTC/RFC3339 values and parsed defensively by the WebUI.
|
||||||
|
- Interface edits preserve interface UUID, private key, public key, and peer associations.
|
||||||
|
- Server endpoint resolution prefers explicit export overrides, then persistent server endpoint settings, with controlled fallback behavior.
|
||||||
|
- Reconciliation detects and repairs server-side peer `AllowedIPs` drift.
|
||||||
|
- Release documentation and testing documentation are promoted to the v1.0.0 baseline.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed road-warrior peers incorrectly receiving client full-tunnel `AllowedIPs` (`0.0.0.0/0, ::/0`) in the server kernel Cryptokey Routing table.
|
||||||
|
- Fixed server-to-peer routing failure caused by missing `/32` peer routes in WireGuard peer configuration.
|
||||||
|
- Fixed WebUI active peers appearing Disconnected because backend `NaiveDateTime` values lacked an explicit UTC offset.
|
||||||
|
- Fixed stale peer telemetry in REST/WebUI responses.
|
||||||
|
- Fixed missing WebUI interface Edit action.
|
||||||
|
- Fixed missing persistent server endpoint for QR/config export.
|
||||||
|
- Fixed reconciliation convergence after deliberate interface-address drift.
|
||||||
|
|
||||||
|
### Networking & Firewall
|
||||||
|
|
||||||
|
- IPv4 forwarding is managed through the native Linux networking engine.
|
||||||
|
- Outbound masquerading is scoped to the WireGuard client subnet and non-WireGuard egress interfaces.
|
||||||
|
- Firewall/NAT state is reconciled atomically within the dedicated NX9 nftables table.
|
||||||
|
- Server-side peer routes and cryptokey routing are kept distinct from client routing policy.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Workspace test suite: **162 tests passing** at the documented release baseline.
|
||||||
|
- Comprehensive CLI suite: **203 passed / 7 skipped**.
|
||||||
|
- Native integration suite: **19 passed / 1 skipped**.
|
||||||
|
- Dedicated live-kernel suite: **23 passed / 1 skipped** in SAFE mode baseline.
|
||||||
|
- Real Android/mobile WireGuard client: **operator-verified** for VPN connectivity and full-tunnel Internet operation during v1.0.0 acceptance.
|
||||||
|
- WebUI interface editing: **operator-verified**.
|
||||||
|
- Live peer telemetry/status: **operator-verified** with connected mobile client.
|
||||||
|
- Final reconciliation: **operator-verified** with zero drift after convergence.
|
||||||
|
- External cellular/WAN road-warrior acceptance and post-reboot physical-client acceptance remain separate operational gates unless explicitly recorded in the release evidence.
|
||||||
|
|
||||||
|
## [0.8.0]
|
||||||
|
|
||||||
|
Previous development release. See repository history for detailed implementation changes.
|
||||||
Generated
+9
-9
@@ -1785,7 +1785,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-wg"
|
name = "nx9-wg"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1807,7 +1807,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-wg-api"
|
name = "nx9-wg-api"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1832,7 +1832,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-wg-core"
|
name = "nx9-wg-core"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1852,7 +1852,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-wg-db"
|
name = "nx9-wg-db"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
@@ -1869,7 +1869,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-wg-network"
|
name = "nx9-wg-network"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1890,7 +1890,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-wg-ui"
|
name = "nx9-wg-ui"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"nx9-wg-core",
|
"nx9-wg-core",
|
||||||
@@ -1901,7 +1901,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-wireguard"
|
name = "nx9-wireguard"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3721,9 +3721,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec-derive"
|
name = "zerovec-derive"
|
||||||
version = "0.11.4"
|
version = "0.11.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
|
checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+3
-3
@@ -10,11 +10,11 @@ members = [
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
version = "0.8.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["NX9 Authors <team@nx9.in>"]
|
authors = ["NX9 Authors <team@nx9.in>"]
|
||||||
repository = "https://github.com/nx9/nx9-wg"
|
repository = "https://github.com/thakares/nx9-wg"
|
||||||
homepage = "https://github.com/nx9/nx9-wg"
|
homepage = "https://github.com/thakares/nx9-wg"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["wireguard", "vpn", "netlink", "nftables", "network"]
|
keywords = ["wireguard", "vpn", "netlink", "nftables", "network"]
|
||||||
categories = ["network-programming", "command-line-utilities", "system-administration"]
|
categories = ["network-programming", "command-line-utilities", "system-administration"]
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
> **"Software people can own, understand, and control."**
|
> **"Software people can own, understand, and control."**
|
||||||
@@ -367,7 +367,7 @@ nx9-wg forwarding enable
|
|||||||
# State Reconciliation & Diagnostics
|
# State Reconciliation & Diagnostics
|
||||||
nx9-wg reconcile plan
|
nx9-wg reconcile plan
|
||||||
nx9-wg reconcile apply
|
nx9-wg reconcile apply
|
||||||
nx9-wg diagnostics inspect all
|
nx9-wg diagnostics all
|
||||||
|
|
||||||
# Disaster Recovery
|
# Disaster Recovery
|
||||||
nx9-wg backup create --description "Pre-upgrade snapshot"
|
nx9-wg backup create --description "Pre-upgrade snapshot"
|
||||||
@@ -464,9 +464,9 @@ LogsDirectory=nx9-wg
|
|||||||
|
|
||||||
### Automated Packaging Pipeline ([`scripts/package-release.sh`](file:///home/sunil/Programs/nx9-wg/scripts/package-release.sh))
|
### 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/`:
|
Generates self-contained, reproducible distribution archives in `target/dist/`:
|
||||||
- `nx9-wg-v0.8.0-linux-x86_64.tar.gz` (7.8 MB)
|
- `nx9-wg-v1.0.0-linux-x86_64.tar.gz` (7.8 MB)
|
||||||
- `nx9-wg-v0.8.0-linux-x86_64.tar.xz` (5.0 MB)
|
- `nx9-wg-v1.0.0-linux-x86_64.tar.xz` (5.0 MB)
|
||||||
- `nx9-wg-v0.8.0-linux-x86_64.sha256` (Cryptographic checksum manifest)
|
- `nx9-wg-v1.0.0-linux-x86_64.sha256` (Cryptographic checksum manifest)
|
||||||
|
|
||||||
### Production Filesystem Layout & Permissions
|
### Production Filesystem Layout & Permissions
|
||||||
```
|
```
|
||||||
@@ -492,7 +492,7 @@ All quality gates have been executed and verified clean:
|
|||||||
| :--- | :--- | :---: |
|
| :--- | :--- | :---: |
|
||||||
| **Code Formatting** | `cargo fmt --all -- --check` | **PASS** (Zero diffs) |
|
| **Code Formatting** | `cargo fmt --all -- --check` | **PASS** (Zero diffs) |
|
||||||
| **Workspace Compilation** | `cargo check --workspace` | **PASS** (Zero errors) |
|
| **Workspace Compilation** | `cargo check --workspace` | **PASS** (Zero errors) |
|
||||||
| **Workspace Unit Tests** | `cargo test --workspace` | **PASS** (**91 / 91 passed**, 100%) |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **Native Integration Suite** | `LIVE=0 bash scripts/test-native-integration.sh` | **PASS** (**19 passed** / 1 skipped) |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
> **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 around the kernel's WireGuard implementation.**
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ That is why **"native Linux VPN + networking platform"** is the accurate descrip
|
|||||||
| **Reconciliation Engine** | **Implemented** | Closed-loop drift detection, read-only plan, and serialized apply |
|
| **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 |
|
| **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 |
|
| **Release & Deployment** | **Implemented** | Standalone installer, uninstaller, packaging script, and systemd unit |
|
||||||
| **SAFE Verification Suite** | **PASS** | 91 workspace tests, 203 CLI tests, 19 integration tests, 23 live tests |
|
| **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` |
|
| **LIVE Kernel Verification** | **Framework Ready** | SAFE mode (`LIVE=0`) verified; dedicated host ready via `LIVE=1` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -152,7 +152,7 @@ That is why **"native Linux VPN + networking platform"** is the accurate descrip
|
|||||||
# Build the release binary
|
# Build the release binary
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# Run the complete test suite (91/91 passed)
|
# Run the complete test suite (162/162 passed)
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -194,8 +194,8 @@ To install `nx9-wg` as a managed systemd service:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download and extract release archive:
|
# Download and extract release archive:
|
||||||
tar -xzf nx9-wg-v0.8.0-linux-x86_64.tar.gz
|
tar -xzf nx9-wg-v1.0.0-linux-x86_64.tar.gz
|
||||||
cd nx9-wg-v0.8.0-linux-x86_64
|
cd nx9-wg-v1.0.0-linux-x86_64
|
||||||
|
|
||||||
# Run production installer:
|
# Run production installer:
|
||||||
sudo bash install.sh
|
sudo bash install.sh
|
||||||
@@ -223,7 +223,7 @@ See the [**Installation & Deployment Guide**](docs/installation.md) for step-by-
|
|||||||
| **Security Architecture** | [**Security Model & Permissions**](docs/security.md) |
|
| **Security Architecture** | [**Security Model & Permissions**](docs/security.md) |
|
||||||
| **Backup & Recovery** | [**Backup & Disaster Recovery**](docs/backup_restore.md) |
|
| **Backup & Recovery** | [**Backup & Disaster Recovery**](docs/backup_restore.md) |
|
||||||
| **Release Engineering** | [**Release Packaging & Systemd**](docs/release.md) |
|
| **Release Engineering** | [**Release Packaging & Systemd**](docs/release.md) |
|
||||||
| **Quality Assurance** | [**Testing Strategy**](docs/testing.md) |
|
| **Quality Assurance** | [**Comprehensive Testing Specification**](docs/TESTING.md) |
|
||||||
| **Developer Guide** | [**Development Guide**](docs/development.md) |
|
| **Developer Guide** | [**Development Guide**](docs/development.md) |
|
||||||
| **Configuration** | [**Configuration Reference**](docs/configuration.md) |
|
| **Configuration** | [**Configuration Reference**](docs/configuration.md) |
|
||||||
| **Containerization** | [**Docker Deployment**](docs/docker.md) |
|
| **Containerization** | [**Docker Deployment**](docs/docker.md) |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::state::{AppState, SystemEvent};
|
use crate::state::{AppState, SystemEvent};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use ipnet::IpNet;
|
||||||
use nx9_wg_core::types::audit::AuditEventType;
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
use nx9_wg_core::types::wireguard::PeerState;
|
use nx9_wg_core::types::wireguard::PeerState;
|
||||||
use nx9_wg_network::NetworkEngine;
|
use nx9_wg_network::NetworkEngine;
|
||||||
@@ -11,6 +12,36 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Check if a slice of live address strings contains the desired IpNet.
|
||||||
|
fn matches_ipnet(live_addrs: &[String], desired: &IpNet) -> bool {
|
||||||
|
live_addrs.iter().any(|s| {
|
||||||
|
if let Ok(net) = s.parse::<IpNet>() {
|
||||||
|
net.addr() == desired.addr() && net.prefix_len() == desired.prefix_len()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if live WireGuard peer allowed IPs match desired server-side allowed IPs.
|
||||||
|
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())
|
||||||
|
.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())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
desired_nets == live_nets
|
||||||
|
}
|
||||||
|
|
||||||
/// Individual action proposed or taken by the reconciler.
|
/// Individual action proposed or taken by the reconciler.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ReconciliationAction {
|
pub struct ReconciliationAction {
|
||||||
@@ -53,6 +84,8 @@ pub struct ReconciliationReport {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub status: ReconciliationStatus,
|
pub status: ReconciliationStatus,
|
||||||
pub executed_actions: usize,
|
pub executed_actions: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub failed_actions: usize,
|
||||||
pub details: Vec<String>,
|
pub details: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,29 +165,56 @@ impl ReconciliationEngine {
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let live_peer_keys: Vec<String> = live_stats
|
let iface_exists = live_interfaces.contains(&iface.name) || live_stats.is_some();
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.peers.iter().map(|p| p.public_key.clone()).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
match live_stats.as_ref() {
|
if iface_exists {
|
||||||
Some(stats) => {
|
if let Some(stats) = live_stats.as_ref() {
|
||||||
if stats.public_key != iface.public_key.as_str()
|
let mut drift_reasons = Vec::new();
|
||||||
|| stats.listen_port != iface.listen_port
|
|
||||||
|
if !stats.public_key.is_empty()
|
||||||
|
&& stats.public_key != iface.public_key.as_str()
|
||||||
{
|
{
|
||||||
|
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 !matches_ipnet(&stats.addresses, &iface.address_v4) {
|
||||||
|
drift_reasons
|
||||||
|
.push(format!("missing IPv4 address '{}'", iface.address_v4));
|
||||||
|
}
|
||||||
|
if let Some(ref v6) = iface.address_v6
|
||||||
|
&& !matches_ipnet(&stats.addresses, v6)
|
||||||
|
{
|
||||||
|
drift_reasons.push(format!("missing IPv6 address '{v6}'"));
|
||||||
|
}
|
||||||
|
if let Some(desired_mtu) = iface.mtu
|
||||||
|
&& let Some(live_mtu) = stats.mtu
|
||||||
|
&& live_mtu != desired_mtu as u32
|
||||||
|
{
|
||||||
|
drift_reasons.push(format!(
|
||||||
|
"MTU mismatch (live: {live_mtu}, desired: {desired_mtu})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !stats.is_up {
|
||||||
|
drift_reasons.push("interface link is down".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !drift_reasons.is_empty() {
|
||||||
plan.actions.push(ReconciliationAction {
|
plan.actions.push(ReconciliationAction {
|
||||||
subsystem: "wireguard".to_string(),
|
subsystem: "wireguard".to_string(),
|
||||||
resource_id: iface.id.to_string(),
|
resource_id: iface.id.to_string(),
|
||||||
action_type: "update_interface".to_string(),
|
action_type: "update_interface".to_string(),
|
||||||
description: format!(
|
description: format!(
|
||||||
"Interface '{}' configuration drift detected; update listen port / keys",
|
"Interface '{}' configuration drift detected ({}); synchronize link, address, port, or keys",
|
||||||
iface.name
|
iface.name,
|
||||||
|
drift_reasons.join(", ")
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
plan.interface_changes += 1;
|
plan.interface_changes += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
} else {
|
||||||
plan.actions.push(ReconciliationAction {
|
plan.actions.push(ReconciliationAction {
|
||||||
subsystem: "wireguard".to_string(),
|
subsystem: "wireguard".to_string(),
|
||||||
resource_id: iface.id.to_string(),
|
resource_id: iface.id.to_string(),
|
||||||
@@ -166,7 +226,6 @@ impl ReconciliationEngine {
|
|||||||
});
|
});
|
||||||
plan.interface_changes += 1;
|
plan.interface_changes += 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check peers (only Active desired peers should be live)
|
// Check peers (only Active desired peers should be live)
|
||||||
let desired_peers = self.state.store.list_peers_for_interface(iface.id).await?;
|
let desired_peers = self.state.store.list_peers_for_interface(iface.id).await?;
|
||||||
@@ -175,16 +234,86 @@ impl ReconciliationEngine {
|
|||||||
.filter(|p| p.state == PeerState::Active)
|
.filter(|p| p.state == PeerState::Active)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let live_peers_map: std::collections::HashMap<
|
||||||
|
String,
|
||||||
|
&nx9_wireguard::LivePeerStats,
|
||||||
|
> = if let Some(ref stats) = live_stats {
|
||||||
|
stats
|
||||||
|
.peers
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.public_key.clone(), p))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
for p in &active_desired_peers {
|
for p in &active_desired_peers {
|
||||||
if !live_peer_keys.contains(&p.public_key.as_str().to_string()) {
|
let pub_key_str = p.public_key.as_str();
|
||||||
|
let desired_server_allowed = p.server_wireguard_allowed_ips();
|
||||||
|
|
||||||
|
if let Some(live_p) = live_peers_map.get(pub_key_str) {
|
||||||
|
// Peer is present in live kernel interface. Verify semantic drift:
|
||||||
|
let mut peer_drifts = Vec::new();
|
||||||
|
|
||||||
|
if !matches_allowed_ips(&live_p.allowed_ips, &desired_server_allowed) {
|
||||||
|
peer_drifts.push(format!(
|
||||||
|
"AllowedIPs drift (live: [{:?}], desired: [{desired_server_allowed}])",
|
||||||
|
live_p.allowed_ips
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(desired_ka), Some(live_ka)) =
|
||||||
|
(p.persistent_keepalive, live_p.persistent_keepalive)
|
||||||
|
&& live_ka != desired_ka
|
||||||
|
{
|
||||||
|
peer_drifts.push(format!(
|
||||||
|
"persistent keepalive drift (live: {live_ka}s, desired: {desired_ka}s)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !peer_drifts.is_empty() {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "wireguard".to_string(),
|
||||||
|
resource_id: p.id.to_string(),
|
||||||
|
action_type: "update_peer".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Peer '{}' ({}) drift detected: {}; re-sync in kernel",
|
||||||
|
p.name,
|
||||||
|
pub_key_str,
|
||||||
|
peer_drifts.join(", ")
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.peer_changes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update operational telemetry (handshake timestamp and learned endpoint) from kernel
|
||||||
|
if live_p.last_handshake_at.is_some() || live_p.endpoint.is_some() {
|
||||||
|
let hs_newer = live_p.last_handshake_at.is_some()
|
||||||
|
&& live_p.last_handshake_at != p.last_handshake_at;
|
||||||
|
let ep_newer = live_p.endpoint.is_some()
|
||||||
|
&& live_p.endpoint.as_deref() != p.endpoint.as_deref();
|
||||||
|
|
||||||
|
if hs_newer || ep_newer {
|
||||||
|
let _ = self
|
||||||
|
.state
|
||||||
|
.store
|
||||||
|
.update_peer_learned_telemetry(
|
||||||
|
p.id,
|
||||||
|
live_p.last_handshake_at.or(p.last_handshake_at),
|
||||||
|
live_p.endpoint.as_deref().or(p.endpoint.as_deref()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
plan.actions.push(ReconciliationAction {
|
plan.actions.push(ReconciliationAction {
|
||||||
subsystem: "wireguard".to_string(),
|
subsystem: "wireguard".to_string(),
|
||||||
resource_id: p.id.to_string(),
|
resource_id: p.id.to_string(),
|
||||||
action_type: "add_peer".to_string(),
|
action_type: "add_peer".to_string(),
|
||||||
description: format!(
|
description: format!(
|
||||||
"Peer '{}' ({}) missing in live interface",
|
"Peer '{}' ({}) missing in live interface; add to kernel with AllowedIPs [{desired_server_allowed}]",
|
||||||
p.name,
|
p.name,
|
||||||
p.public_key.as_str()
|
pub_key_str
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
plan.peer_changes += 1;
|
plan.peer_changes += 1;
|
||||||
@@ -293,7 +422,7 @@ impl ReconciliationEngine {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if expected_ruleset.trim() != active_ruleset.trim() {
|
if nx9_wg_network::has_nftables_drift(&expected_ruleset, &active_ruleset) {
|
||||||
plan.actions.push(ReconciliationAction {
|
plan.actions.push(ReconciliationAction {
|
||||||
subsystem: "firewall".to_string(),
|
subsystem: "firewall".to_string(),
|
||||||
resource_id: "nftables".to_string(),
|
resource_id: "nftables".to_string(),
|
||||||
@@ -340,6 +469,17 @@ impl ReconciliationEngine {
|
|||||||
// Sweep expired peers
|
// Sweep expired peers
|
||||||
let _ = self.sweep_expired_peers().await;
|
let _ = self.sweep_expired_peers().await;
|
||||||
|
|
||||||
|
let initial_plan = self.plan().await.unwrap_or_default();
|
||||||
|
if !initial_plan.has_drift {
|
||||||
|
return Ok(ReconciliationReport {
|
||||||
|
success: true,
|
||||||
|
status: ReconciliationStatus::Converged,
|
||||||
|
executed_actions: 0,
|
||||||
|
failed_actions: 0,
|
||||||
|
details: vec!["System is already fully converged; zero drift detected".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let desired_interfaces = self.state.store.list_interfaces().await?;
|
let desired_interfaces = self.state.store.list_interfaces().await?;
|
||||||
let mut details = Vec::new();
|
let mut details = Vec::new();
|
||||||
|
|
||||||
@@ -419,10 +559,28 @@ impl ReconciliationEngine {
|
|||||||
|
|
||||||
// 4. Verify post-apply convergence
|
// 4. Verify post-apply convergence
|
||||||
let post_plan = self.plan().await.unwrap_or_default();
|
let post_plan = self.plan().await.unwrap_or_default();
|
||||||
let (success, status) = if !post_plan.has_drift {
|
let (success, status, executed_actions, failed_actions) = if !post_plan.has_drift {
|
||||||
(true, ReconciliationStatus::Converged)
|
(
|
||||||
|
true,
|
||||||
|
ReconciliationStatus::Converged,
|
||||||
|
initial_plan.actions.len(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(false, ReconciliationStatus::DriftRemains)
|
let remaining = post_plan.actions.len();
|
||||||
|
let completed = initial_plan.actions.len().saturating_sub(remaining);
|
||||||
|
for action in &post_plan.actions {
|
||||||
|
details.push(format!(
|
||||||
|
"Unresolved drift: [{}] {}",
|
||||||
|
action.subsystem, action.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(
|
||||||
|
false,
|
||||||
|
ReconciliationStatus::DriftRemains,
|
||||||
|
completed,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. Audit reconciliation run
|
// 5. Audit reconciliation run
|
||||||
@@ -435,8 +593,8 @@ impl ReconciliationEngine {
|
|||||||
Some("reconciliation"),
|
Some("reconciliation"),
|
||||||
None,
|
None,
|
||||||
Some(&format!(
|
Some(&format!(
|
||||||
"Reconciliation applied {} actions (status: {status:?})",
|
"Reconciliation applied {} actions (status: {status:?}, failed: {failed_actions})",
|
||||||
details.len()
|
executed_actions
|
||||||
)),
|
)),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -446,8 +604,8 @@ impl ReconciliationEngine {
|
|||||||
self.state.broadcast(SystemEvent::AuditEvent {
|
self.state.broadcast(SystemEvent::AuditEvent {
|
||||||
event_type: AuditEventType::ReconciliationRun,
|
event_type: AuditEventType::ReconciliationRun,
|
||||||
message: Some(format!(
|
message: Some(format!(
|
||||||
"Reconciliation applied {} actions (status: {status:?})",
|
"Reconciliation applied {} actions (status: {status:?}, failed: {failed_actions})",
|
||||||
details.len()
|
executed_actions
|
||||||
)),
|
)),
|
||||||
resource_type: Some("reconciliation".to_string()),
|
resource_type: Some("reconciliation".to_string()),
|
||||||
resource_id: None,
|
resource_id: None,
|
||||||
@@ -456,7 +614,8 @@ impl ReconciliationEngine {
|
|||||||
Ok(ReconciliationReport {
|
Ok(ReconciliationReport {
|
||||||
success,
|
success,
|
||||||
status,
|
status,
|
||||||
executed_actions: details.len(),
|
executed_actions,
|
||||||
|
failed_actions,
|
||||||
details,
|
details,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,31 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app-layout">
|
<!-- Unauthenticated Login View -->
|
||||||
|
<div id="login-view" style="display: none;">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<span class="brand-mark">NX9</span>
|
||||||
|
<h2>Administrator Login</h2>
|
||||||
|
<p>Sign in to nx9-wg Native Linux Appliance</p>
|
||||||
|
</div>
|
||||||
|
<div id="login-error-msg" class="login-error" style="display: none;"></div>
|
||||||
|
<form id="login-form" onsubmit="event.preventDefault(); submitLogin();">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="login-username">Username</label>
|
||||||
|
<input type="text" id="login-username" class="form-input" value="admin" required autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="login-password">Password</label>
|
||||||
|
<input type="password" id="login-password" class="form-input" required autofocus autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="login-submit-btn" class="btn btn-primary" style="width: 100%; margin-top: 8px;">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authenticated Application Shell -->
|
||||||
|
<div id="app-layout" style="display: none;">
|
||||||
<!-- Top Application Bar -->
|
<!-- Top Application Bar -->
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-left">
|
<div class="topbar-left">
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
use crate::auth::middleware::AuthenticatedAdmin;
|
use crate::auth::middleware::AuthenticatedAdmin;
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Request, State};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use axum::http::header::SET_COOKIE;
|
use axum::http::header::{AUTHORIZATION, COOKIE, SET_COOKIE};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{Extension, Json};
|
use axum::{Extension, Json};
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
@@ -67,9 +67,8 @@ pub async fn login_handler(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let cookie_val = format!(
|
let cookie_val = format!(
|
||||||
"nx9_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
|
"nx9_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400",
|
||||||
session.id,
|
session.id
|
||||||
24 * 3600
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
@@ -89,12 +88,37 @@ pub async fn login_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/v1/auth/logout
|
/// POST /api/v1/auth/logout
|
||||||
pub async fn logout_handler(
|
pub async fn logout_handler(State(state): State<AppState>, req: Request) -> ApiResult<Response> {
|
||||||
State(state): State<AppState>,
|
let mut session_to_delete = None;
|
||||||
Extension(auth_user): Extension<AuthenticatedAdmin>,
|
|
||||||
) -> ApiResult<Response> {
|
// 1. Try Bearer token in Authorization header
|
||||||
if let Some(ref session_id) = auth_user.session_id {
|
if let Some(token) = req
|
||||||
state.auth.logout(session_id, None).await?;
|
.headers()
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|h| h.strip_prefix("Bearer "))
|
||||||
|
{
|
||||||
|
let token = token.trim();
|
||||||
|
if !token.starts_with("nx9_") {
|
||||||
|
session_to_delete = Some(token.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try session cookie (nx9_session=...)
|
||||||
|
if session_to_delete.is_none()
|
||||||
|
&& let Some(cookie_header) = req.headers().get(COOKIE).and_then(|v| v.to_str().ok())
|
||||||
|
{
|
||||||
|
for cookie in cookie_header.split(';') {
|
||||||
|
let cookie = cookie.trim();
|
||||||
|
if let Some(session_id) = cookie.strip_prefix("nx9_session=") {
|
||||||
|
session_to_delete = Some(session_id.trim().to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref session_id) = session_to_delete {
|
||||||
|
let _ = state.auth.logout(session_id, None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cookie_val = "nx9_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
|
let cookie_val = "nx9_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub struct UpdateInterfaceRequest {
|
|||||||
pub address_v6: Option<String>,
|
pub address_v6: Option<String>,
|
||||||
pub mtu: Option<u16>,
|
pub mtu: Option<u16>,
|
||||||
pub dns: Option<String>,
|
pub dns: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
pub pre_up: Option<String>,
|
pub pre_up: Option<String>,
|
||||||
pub post_up: Option<String>,
|
pub post_up: Option<String>,
|
||||||
pub pre_down: Option<String>,
|
pub pre_down: Option<String>,
|
||||||
@@ -144,8 +145,18 @@ pub async fn update_interface_handler(
|
|||||||
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
||||||
|
|
||||||
if let Some(ref name) = payload.name {
|
if let Some(ref name) = payload.name {
|
||||||
validate_interface_name(name)?;
|
let trimmed = name.trim();
|
||||||
iface.name = name.clone();
|
validate_interface_name(trimmed)?;
|
||||||
|
if iface.name != trimmed {
|
||||||
|
if let Ok(Some(existing)) = state.store.get_interface_by_name(trimmed).await
|
||||||
|
&& existing.id != iface.id
|
||||||
|
{
|
||||||
|
return Err(ApiError::Validation(format!(
|
||||||
|
"Interface with name '{trimmed}' already exists"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
iface.name = trimmed.to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(port) = payload.listen_port {
|
if let Some(port) = payload.listen_port {
|
||||||
validate_listen_port(port)?;
|
validate_listen_port(port)?;
|
||||||
@@ -155,14 +166,25 @@ pub async fn update_interface_handler(
|
|||||||
iface.address_v4 = validate_cidr(v4)?;
|
iface.address_v4 = validate_cidr(v4)?;
|
||||||
}
|
}
|
||||||
if let Some(ref v6) = payload.address_v6 {
|
if let Some(ref v6) = payload.address_v6 {
|
||||||
iface.address_v6 = Some(validate_cidr(v6)?);
|
if v6.trim().is_empty() {
|
||||||
|
iface.address_v6 = None;
|
||||||
|
} else {
|
||||||
|
iface.address_v6 = Some(validate_cidr(v6.trim())?);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(m) = payload.mtu {
|
if let Some(m) = payload.mtu {
|
||||||
validate_mtu(m)?;
|
validate_mtu(m)?;
|
||||||
iface.mtu = Some(m);
|
iface.mtu = Some(m);
|
||||||
}
|
}
|
||||||
if let Some(ref dns) = payload.dns {
|
if let Some(ref dns) = payload.dns {
|
||||||
iface.dns = Some(dns.clone());
|
if dns.trim().is_empty() {
|
||||||
|
iface.dns = None;
|
||||||
|
} else {
|
||||||
|
iface.dns = Some(dns.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(en) = payload.enabled {
|
||||||
|
iface.enabled = en;
|
||||||
}
|
}
|
||||||
if payload.pre_up.is_some() {
|
if payload.pre_up.is_some() {
|
||||||
iface.pre_up = payload.pre_up;
|
iface.pre_up = payload.pre_up;
|
||||||
@@ -177,6 +199,8 @@ pub async fn update_interface_handler(
|
|||||||
iface.post_down = payload.post_down;
|
iface.post_down = payload.post_down;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
iface.updated_at = Utc::now().naive_utc();
|
||||||
|
|
||||||
state.store.update_interface(&iface).await?;
|
state.store.update_interface(&iface).await?;
|
||||||
|
|
||||||
state.broadcast(SystemEvent::InterfaceChanged {
|
state.broadcast(SystemEvent::InterfaceChanged {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub fn build_api_router(state: AppState) -> Router {
|
|||||||
// 1. Protected routes (require authenticated admin via session or token)
|
// 1. Protected routes (require authenticated admin via session or token)
|
||||||
let protected_router = Router::new()
|
let protected_router = Router::new()
|
||||||
// Auth management
|
// Auth management
|
||||||
.route("/auth/logout", post(auth::logout_handler))
|
|
||||||
.route("/auth/session", get(auth::session_handler))
|
.route("/auth/session", get(auth::session_handler))
|
||||||
.route("/auth/password", post(auth::change_password_handler))
|
.route("/auth/password", post(auth::change_password_handler))
|
||||||
.route("/auth/tokens", post(auth::create_token_handler))
|
.route("/auth/tokens", post(auth::create_token_handler))
|
||||||
@@ -36,6 +35,7 @@ pub fn build_api_router(state: AppState) -> Router {
|
|||||||
.route("/auth/tokens/{id}", delete(auth::revoke_token_handler))
|
.route("/auth/tokens/{id}", delete(auth::revoke_token_handler))
|
||||||
// System
|
// System
|
||||||
.route("/system", get(system::system_overview_handler))
|
.route("/system", get(system::system_overview_handler))
|
||||||
|
.route("/system/live-state", get(system::live_state_handler))
|
||||||
.route("/system/settings", get(system::list_settings_handler))
|
.route("/system/settings", get(system::list_settings_handler))
|
||||||
.route("/system/settings", put(system::upsert_setting_handler))
|
.route("/system/settings", put(system::upsert_setting_handler))
|
||||||
// Interfaces
|
// Interfaces
|
||||||
@@ -68,6 +68,7 @@ pub fn build_api_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/interfaces/{id}/peers", post(peers::create_peer_handler))
|
.route("/interfaces/{id}/peers", post(peers::create_peer_handler))
|
||||||
// Peers
|
// Peers
|
||||||
|
.route("/peers", get(peers::list_peers_handler))
|
||||||
.route("/peers/{id}", get(peers::get_peer_handler))
|
.route("/peers/{id}", get(peers::get_peer_handler))
|
||||||
.route("/peers/{id}", put(peers::update_peer_handler))
|
.route("/peers/{id}", put(peers::update_peer_handler))
|
||||||
.route("/peers/{id}", delete(peers::delete_peer_handler))
|
.route("/peers/{id}", delete(peers::delete_peer_handler))
|
||||||
@@ -191,6 +192,7 @@ pub fn build_api_router(state: AppState) -> Router {
|
|||||||
// 2. Public API routes (no authentication required)
|
// 2. Public API routes (no authentication required)
|
||||||
let public_router = Router::new()
|
let public_router = Router::new()
|
||||||
.route("/auth/login", post(auth::login_handler))
|
.route("/auth/login", post(auth::login_handler))
|
||||||
|
.route("/auth/logout", post(auth::logout_handler))
|
||||||
.route("/system/health", get(system::health_handler))
|
.route("/system/health", get(system::health_handler))
|
||||||
.route("/system/version", get(system::version_handler))
|
.route("/system/version", get(system::version_handler))
|
||||||
.route("/ws", get(ws::ws_handler));
|
.route("/ws", get(ws::ws_handler));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use axum::Json;
|
|||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use ipnet::IpNet;
|
||||||
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||||
use nx9_wg_core::types::network::Network;
|
use nx9_wg_core::types::network::Network;
|
||||||
use nx9_wg_core::types::wireguard::{
|
use nx9_wg_core::types::wireguard::{
|
||||||
@@ -67,13 +68,200 @@ pub struct PeerLifecycleResponse {
|
|||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PeerResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub interface_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub peer_type: PeerType,
|
||||||
|
pub state: PeerState,
|
||||||
|
pub public_key: WireGuardPublicKey,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub private_key: Option<WireGuardPrivateKey>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preshared_key: Option<WireGuardPresharedKey>,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub allowed_ips: String,
|
||||||
|
pub server_allowed_ips: Option<String>,
|
||||||
|
pub address_v4: Option<IpNet>,
|
||||||
|
pub address_v6: Option<IpNet>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub persistent_keepalive: Option<u16>,
|
||||||
|
pub profile: PeerProfile,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub last_handshake_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rx_bytes: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tx_bytes: Option<u64>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_utc_rfc3339(dt: NaiveDateTime) -> String {
|
||||||
|
let utc_dt = chrono::DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc);
|
||||||
|
utc_dt.to_rfc3339()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_peer_response(peer: Peer, live_stats: Option<&nx9_wireguard::LivePeerStats>) -> PeerResponse {
|
||||||
|
let (endpoint, last_handshake_at, rx_bytes, tx_bytes) = if let Some(live) = live_stats {
|
||||||
|
let ep = live.endpoint.clone().or(peer.endpoint.clone());
|
||||||
|
let hs = live.last_handshake_at.or(peer.last_handshake_at);
|
||||||
|
(ep, hs, Some(live.rx_bytes), Some(live.tx_bytes))
|
||||||
|
} else {
|
||||||
|
(peer.endpoint.clone(), peer.last_handshake_at, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
PeerResponse {
|
||||||
|
id: peer.id,
|
||||||
|
interface_id: peer.interface_id,
|
||||||
|
name: peer.name,
|
||||||
|
peer_type: peer.peer_type,
|
||||||
|
state: peer.state,
|
||||||
|
public_key: peer.public_key,
|
||||||
|
private_key: peer.private_key,
|
||||||
|
preshared_key: peer.preshared_key,
|
||||||
|
endpoint,
|
||||||
|
allowed_ips: peer.allowed_ips,
|
||||||
|
server_allowed_ips: peer.server_allowed_ips,
|
||||||
|
address_v4: peer.address_v4,
|
||||||
|
address_v6: peer.address_v6,
|
||||||
|
dns: peer.dns,
|
||||||
|
mtu: peer.mtu,
|
||||||
|
persistent_keepalive: peer.persistent_keepalive,
|
||||||
|
profile: peer.profile,
|
||||||
|
expires_at: peer.expires_at.map(format_utc_rfc3339),
|
||||||
|
last_handshake_at: last_handshake_at.map(format_utc_rfc3339),
|
||||||
|
rx_bytes,
|
||||||
|
tx_bytes,
|
||||||
|
created_at: format_utc_rfc3339(peer.created_at),
|
||||||
|
updated_at: format_utc_rfc3339(peer.updated_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enrich_peers_with_live_telemetry(state: &AppState, peers: Vec<Peer>) -> Vec<PeerResponse> {
|
||||||
|
if peers.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Gather all unique interface IDs from peers and find interface names
|
||||||
|
let mut iface_map = std::collections::HashMap::new();
|
||||||
|
for p in &peers {
|
||||||
|
if !iface_map.contains_key(&p.interface_id)
|
||||||
|
&& let Ok(Some(iface)) = state.store.get_interface(p.interface_id).await
|
||||||
|
{
|
||||||
|
iface_map.insert(p.interface_id, iface.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Query live interface stats for each interface
|
||||||
|
let mut live_map = std::collections::HashMap::new();
|
||||||
|
for iface_name in iface_map.values() {
|
||||||
|
if let Ok(Some(stats)) = state.wg_engine.get_interface_stats(iface_name).await {
|
||||||
|
for lp in stats.peers {
|
||||||
|
live_map.insert(lp.public_key.clone(), lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Construct enriched PeerResponse and update DB cache if newer
|
||||||
|
let mut responses = Vec::with_capacity(peers.len());
|
||||||
|
for p in peers {
|
||||||
|
let pub_key_str = p.public_key.to_string();
|
||||||
|
let live_stat = live_map.get(&pub_key_str);
|
||||||
|
|
||||||
|
if let Some(live) = live_stat {
|
||||||
|
let hs_newer =
|
||||||
|
live.last_handshake_at.is_some() && live.last_handshake_at != p.last_handshake_at;
|
||||||
|
let ep_newer =
|
||||||
|
live.endpoint.is_some() && live.endpoint.as_deref() != p.endpoint.as_deref();
|
||||||
|
|
||||||
|
if hs_newer || ep_newer {
|
||||||
|
let latest_hs = live.last_handshake_at.or(p.last_handshake_at);
|
||||||
|
let latest_ep = live.endpoint.as_deref().or(p.endpoint.as_deref());
|
||||||
|
let _ = state
|
||||||
|
.store
|
||||||
|
.update_peer_learned_telemetry(p.id, latest_hs, latest_ep)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.push(to_peer_response(p, live_stat));
|
||||||
|
}
|
||||||
|
|
||||||
|
responses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/peers
|
||||||
|
pub async fn list_peers_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<PeerResponse>>> {
|
||||||
|
let peers = state.store.list_all_peers().await?;
|
||||||
|
let enriched = enrich_peers_with_live_telemetry(&state, peers).await;
|
||||||
|
Ok(Json(enriched))
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/v1/interfaces/{id}/peers
|
/// GET /api/v1/interfaces/{id}/peers
|
||||||
pub async fn list_peers_for_interface_handler(
|
pub async fn list_peers_for_interface_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(interface_id): Path<Uuid>,
|
Path(interface_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<Vec<Peer>>> {
|
) -> ApiResult<Json<Vec<PeerResponse>>> {
|
||||||
let peers = state.store.list_peers_for_interface(interface_id).await?;
|
let peers = state.store.list_peers_for_interface(interface_id).await?;
|
||||||
Ok(Json(peers))
|
let enriched = enrich_peers_with_live_telemetry(&state, peers).await;
|
||||||
|
Ok(Json(enriched))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_no_server_allowed_ips_conflict(
|
||||||
|
store: &nx9_wg_db::Store,
|
||||||
|
interface_id: Uuid,
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
candidate_server_allowed_ips: &str,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
if candidate_server_allowed_ips.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate_nets: Vec<IpNet> = candidate_server_allowed_ips
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if candidate_nets.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_peers = store.list_peers_for_interface(interface_id).await?;
|
||||||
|
for ep in existing_peers {
|
||||||
|
if ep.state != PeerState::Active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if Some(ep.id) == peer_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ep_server_allowed = ep.server_wireguard_allowed_ips();
|
||||||
|
let ep_nets: Vec<IpNet> = ep_server_allowed
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.filter_map(|s| s.parse::<IpNet>().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for n1 in &candidate_nets {
|
||||||
|
for n2 in &ep_nets {
|
||||||
|
if n1.contains(n2) || n2.contains(n1) {
|
||||||
|
return Err(ApiError::Validation(format!(
|
||||||
|
"Server-side AllowedIP '{n1}' overlaps with active peer '{}' AllowedIP '{n2}'",
|
||||||
|
ep.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/v1/interfaces/{id}/peers
|
/// POST /api/v1/interfaces/{id}/peers
|
||||||
@@ -182,6 +370,15 @@ pub async fn create_peer_handler(
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validate no overlapping server-side AllowedIPs with active peers on the same interface
|
||||||
|
validate_no_server_allowed_ips_conflict(
|
||||||
|
&state.store,
|
||||||
|
interface_id,
|
||||||
|
None,
|
||||||
|
&peer.server_wireguard_allowed_ips(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
state.store.create_peer(&peer).await?;
|
state.store.create_peer(&peer).await?;
|
||||||
|
|
||||||
state.broadcast(SystemEvent::PeerChanged {
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
@@ -196,13 +393,15 @@ pub async fn create_peer_handler(
|
|||||||
pub async fn get_peer_handler(
|
pub async fn get_peer_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<Peer>> {
|
) -> ApiResult<Json<PeerResponse>> {
|
||||||
let peer = state
|
let peer = state
|
||||||
.store
|
.store
|
||||||
.get_peer(id)
|
.get_peer(id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
||||||
Ok(Json(peer))
|
let mut enriched = enrich_peers_with_live_telemetry(&state, vec![peer]).await;
|
||||||
|
let peer_resp = enriched.pop().unwrap();
|
||||||
|
Ok(Json(peer_resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/v1/peers/{id}
|
/// PUT /api/v1/peers/{id}
|
||||||
@@ -256,6 +455,15 @@ pub async fn update_peer_handler(
|
|||||||
peer.expires_at = payload.expires_at;
|
peer.expires_at = payload.expires_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate no overlapping server-side AllowedIPs with active peers on the same interface
|
||||||
|
validate_no_server_allowed_ips_conflict(
|
||||||
|
&state.store,
|
||||||
|
peer.interface_id,
|
||||||
|
Some(peer.id),
|
||||||
|
&peer.server_wireguard_allowed_ips(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
state.store.update_peer(&peer).await?;
|
state.store.update_peer(&peer).await?;
|
||||||
|
|
||||||
state.broadcast(SystemEvent::PeerChanged {
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
@@ -391,6 +599,46 @@ pub struct ClientProfileQuery {
|
|||||||
pub nat: Option<String>,
|
pub nat: Option<String>,
|
||||||
pub mtu: Option<u16>,
|
pub mtu: Option<u16>,
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
|
pub server_endpoint: Option<String>,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_server_endpoint(
|
||||||
|
state: &AppState,
|
||||||
|
query: &ClientProfileQuery,
|
||||||
|
) -> ApiResult<String> {
|
||||||
|
// 1. Explicit query parameter (server_endpoint or endpoint)
|
||||||
|
if let Some(ep) = 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(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -418,12 +666,7 @@ pub async fn download_peer_config_handler(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
|
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
|
||||||
|
|
||||||
let host = state
|
let host = resolve_server_endpoint(&state, &query).await?;
|
||||||
.store
|
|
||||||
.get_setting("server_endpoint")
|
|
||||||
.await?
|
|
||||||
.map(|s| s.value)
|
|
||||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
|
||||||
|
|
||||||
let resolved_profile = if query.provider.is_some()
|
let resolved_profile = if query.provider.is_some()
|
||||||
|| query.device.is_some()
|
|| query.device.is_some()
|
||||||
@@ -509,12 +752,7 @@ pub async fn get_peer_qr_handler(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
|
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
|
||||||
|
|
||||||
let host = state
|
let host = resolve_server_endpoint(&state, &query).await?;
|
||||||
.store
|
|
||||||
.get_setting("server_endpoint")
|
|
||||||
.await?
|
|
||||||
.map(|s| s.value)
|
|
||||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
|
||||||
|
|
||||||
let resolved_profile = if query.provider.is_some()
|
let resolved_profile = if query.provider.is_some()
|
||||||
|| query.device.is_some()
|
|| query.device.is_some()
|
||||||
|
|||||||
@@ -94,24 +94,129 @@ pub async fn upsert_setting_handler(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<UpsertSettingRequest>,
|
Json(payload): Json<UpsertSettingRequest>,
|
||||||
) -> ApiResult<Json<GenericSuccess>> {
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
if payload.key.trim().is_empty() {
|
let key_trimmed = payload.key.trim();
|
||||||
|
if key_trimmed.is_empty() {
|
||||||
return Err(ApiError::Validation(
|
return Err(ApiError::Validation(
|
||||||
"Setting key cannot be empty".to_string(),
|
"Setting key cannot be empty".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let val_trimmed = payload.value.trim();
|
||||||
|
if (key_trimmed == "server_endpoint" || key_trimmed == "public_endpoint")
|
||||||
|
&& !val_trimmed.is_empty()
|
||||||
|
{
|
||||||
|
let has_valid_port = if let Some(last_colon) = val_trimmed.rfind(':') {
|
||||||
|
let port_str = &val_trimmed[last_colon + 1..];
|
||||||
|
if let Ok(port) = port_str.parse::<u16>() {
|
||||||
|
port > 0 && !val_trimmed[..last_colon].trim().is_empty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_valid_port {
|
||||||
|
return Err(ApiError::Validation(format!(
|
||||||
|
"Invalid server endpoint '{val_trimmed}'. Endpoint must be formatted as host:port (e.g. 192.168.1.8:51820 or vpn.domain.com:51820)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let is_secret = payload.is_secret.unwrap_or(false);
|
let is_secret = payload.is_secret.unwrap_or(false);
|
||||||
state
|
state
|
||||||
.store
|
.store
|
||||||
.set_setting(&payload.key, &payload.value, is_secret)
|
.set_setting(key_trimmed, val_trimmed, is_secret)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
state.broadcast(SystemEvent::SettingsChanged {
|
state.broadcast(SystemEvent::SettingsChanged {
|
||||||
key: payload.key.clone(),
|
key: key_trimmed.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Json(GenericSuccess {
|
Ok(Json(GenericSuccess {
|
||||||
success: true,
|
success: true,
|
||||||
message: format!("Setting '{}' saved successfully", payload.key),
|
message: format!("Setting '{key_trimmed}' saved"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LiveInterfaceTelemetry {
|
||||||
|
pub name: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub listen_port: u16,
|
||||||
|
pub fwmark: u32,
|
||||||
|
pub addresses: Vec<String>,
|
||||||
|
pub mtu: Option<u32>,
|
||||||
|
pub is_up: bool,
|
||||||
|
pub peer_count: usize,
|
||||||
|
pub peers: Vec<nx9_wireguard::LivePeerStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LiveRouteTelemetry {
|
||||||
|
pub destination: String,
|
||||||
|
pub gateway: Option<String>,
|
||||||
|
pub metric: Option<u32>,
|
||||||
|
pub table: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LiveSystemState {
|
||||||
|
pub interfaces: Vec<LiveInterfaceTelemetry>,
|
||||||
|
pub routes: Vec<LiveRouteTelemetry>,
|
||||||
|
pub ipv4_forwarding: bool,
|
||||||
|
pub ipv6_forwarding: bool,
|
||||||
|
pub active_nftables: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/system/live-state
|
||||||
|
pub async fn live_state_handler(State(state): State<AppState>) -> ApiResult<Json<LiveSystemState>> {
|
||||||
|
let iface_names = state.wg_engine.list_interfaces().await.unwrap_or_default();
|
||||||
|
let mut interfaces = Vec::new();
|
||||||
|
|
||||||
|
for name in iface_names {
|
||||||
|
if let Ok(Some(stats)) = state.wg_engine.get_interface_stats(&name).await {
|
||||||
|
let peer_count = stats.peers.len();
|
||||||
|
interfaces.push(LiveInterfaceTelemetry {
|
||||||
|
name: stats.name,
|
||||||
|
public_key: stats.public_key,
|
||||||
|
listen_port: stats.listen_port,
|
||||||
|
fwmark: stats.fwmark,
|
||||||
|
addresses: stats.addresses,
|
||||||
|
mtu: stats.mtu,
|
||||||
|
is_up: stats.is_up,
|
||||||
|
peer_count,
|
||||||
|
peers: stats.peers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let desired_routes = state.store.list_routes().await.unwrap_or_default();
|
||||||
|
let routes = desired_routes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| r.enabled)
|
||||||
|
.map(|r| LiveRouteTelemetry {
|
||||||
|
destination: r.destination.to_string(),
|
||||||
|
gateway: r.gateway.map(|g| g.to_string()),
|
||||||
|
metric: r.metric,
|
||||||
|
table: 254,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let fwd = state.net_engine.get_forwarding_status().await.unwrap_or(
|
||||||
|
nx9_wg_network::IpForwardingStatus {
|
||||||
|
ipv4_enabled: false,
|
||||||
|
ipv6_enabled: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let active_nftables = state.net_engine.get_active_nftables_ruleset().await.ok();
|
||||||
|
|
||||||
|
Ok(Json(LiveSystemState {
|
||||||
|
interfaces,
|
||||||
|
routes,
|
||||||
|
ipv4_forwarding: fwd.ipv4_enabled,
|
||||||
|
ipv6_forwarding: fwd.ipv6_enabled,
|
||||||
|
active_nftables,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,14 @@
|
|||||||
use crate::auth::service::AuthService;
|
use crate::auth::service::AuthService;
|
||||||
use nx9_wg_core::types::audit::AuditEventType;
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
use nx9_wg_db::Store;
|
use nx9_wg_db::Store;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use nx9_wg_network::engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use nx9_wireguard::engine::{
|
||||||
|
NativeLinuxWireGuardEngine, SimulatedWireGuardEngine, WireGuardEngine,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/// Real-time system event broadcasted over WebSocket to connected clients.
|
/// Real-time system event broadcasted over WebSocket to connected clients.
|
||||||
@@ -39,17 +46,41 @@ pub struct AppState {
|
|||||||
pub store: Store,
|
pub store: Store,
|
||||||
pub auth: AuthService,
|
pub auth: AuthService,
|
||||||
pub event_tx: broadcast::Sender<SystemEvent>,
|
pub event_tx: broadcast::Sender<SystemEvent>,
|
||||||
|
pub wg_engine: Arc<dyn WireGuardEngine>,
|
||||||
|
pub net_engine: Arc<dyn NetworkEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// Create a new AppState instance.
|
/// Create a new AppState instance with default engines.
|
||||||
pub fn new(store: Store) -> Self {
|
pub fn new(store: Store) -> Self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let (wg, net): (Arc<dyn WireGuardEngine>, Arc<dyn NetworkEngine>) = (
|
||||||
|
Arc::new(NativeLinuxWireGuardEngine::new()),
|
||||||
|
Arc::new(NativeLinuxNetworkEngine::new()),
|
||||||
|
);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let (wg, net): (Arc<dyn WireGuardEngine>, Arc<dyn NetworkEngine>) = (
|
||||||
|
Arc::new(SimulatedWireGuardEngine::new()),
|
||||||
|
Arc::new(SimulatedNetworkEngine::new()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self::with_engines(store, wg, net)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new AppState instance with custom engines.
|
||||||
|
pub fn with_engines(
|
||||||
|
store: Store,
|
||||||
|
wg_engine: Arc<dyn WireGuardEngine>,
|
||||||
|
net_engine: Arc<dyn NetworkEngine>,
|
||||||
|
) -> Self {
|
||||||
let (event_tx, _) = broadcast::channel(256);
|
let (event_tx, _) = broadcast::channel(256);
|
||||||
let auth = AuthService::new(store.clone());
|
let auth = AuthService::new(store.clone());
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
auth,
|
auth,
|
||||||
event_tx,
|
event_tx,
|
||||||
|
wg_engine,
|
||||||
|
net_engine,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,3 +253,54 @@ async fn test_auth_service_api_tokens() {
|
|||||||
"revoked token must fail authentication"
|
"revoked token must fail authentication"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_service_logout_invalidates_session_and_is_idempotent() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let config = AppConfig::default();
|
||||||
|
let opts = BootstrapOptions {
|
||||||
|
cli_password: Some("AdminSecret123!".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
bootstrap_admin(&store, &config, &opts)
|
||||||
|
.await
|
||||||
|
.expect("bootstrap");
|
||||||
|
|
||||||
|
let auth = AuthService::new(store);
|
||||||
|
|
||||||
|
// Create 2 sessions
|
||||||
|
let s1 = auth
|
||||||
|
.login("admin", "AdminSecret123!", Some("10.0.0.1"), None)
|
||||||
|
.await
|
||||||
|
.expect("login 1");
|
||||||
|
let s2 = auth
|
||||||
|
.login("admin", "AdminSecret123!", Some("10.0.0.2"), None)
|
||||||
|
.await
|
||||||
|
.expect("login 2");
|
||||||
|
|
||||||
|
assert!(auth.authenticate_session(&s1.id).await.is_ok());
|
||||||
|
assert!(auth.authenticate_session(&s2.id).await.is_ok());
|
||||||
|
|
||||||
|
// Logout session 1
|
||||||
|
auth.logout(&s1.id, Some("10.0.0.1"))
|
||||||
|
.await
|
||||||
|
.expect("logout s1");
|
||||||
|
|
||||||
|
// Session 1 is invalidated; Session 2 remains valid
|
||||||
|
assert!(
|
||||||
|
auth.authenticate_session(&s1.id).await.is_err(),
|
||||||
|
"s1 must be rejected after logout"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
auth.authenticate_session(&s2.id).await.is_ok(),
|
||||||
|
"s2 must remain valid"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Repeated logout of s1 is safe/idempotent
|
||||||
|
assert!(
|
||||||
|
auth.logout(&s1.id, Some("10.0.0.1")).await.is_ok(),
|
||||||
|
"repeated logout must be safe and idempotent"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,6 +79,10 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
store.create_peer(&peer).await.unwrap();
|
store.create_peer(&peer).await.unwrap();
|
||||||
|
store
|
||||||
|
.set_setting("server_endpoint", "vpn.example.com", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let state = AppState::new(store);
|
let state = AppState::new(store);
|
||||||
let app = nx9_wg_api::routes::build_api_router(state.clone());
|
let app = nx9_wg_api::routes::build_api_router(state.clone());
|
||||||
@@ -88,7 +92,7 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_client_profiles_endpoints() {
|
async fn test_client_profiles_endpoints() {
|
||||||
let (app, _state, session_id, _iface, peer) = setup_test_app().await;
|
let (app, state, session_id, interface, peer) = setup_test_app().await;
|
||||||
|
|
||||||
// 1. List client profiles
|
// 1. List client profiles
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
@@ -181,4 +185,67 @@ async fn test_client_profiles_endpoints() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
|
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
|
||||||
|
|
||||||
|
// 7. Delete server_endpoint setting and verify config export fails with actionable error
|
||||||
|
state.store.delete_setting("server_endpoint").await.unwrap();
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
err_json["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("No reachable WireGuard server endpoint is configured")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. With query server_endpoint parameter, export succeeds even without DB setting
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/api/v1/peers/{}/config?server_endpoint=custom.vpn.io:51820",
|
||||||
|
peer.id
|
||||||
|
))
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let conf_str = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(conf_str.contains("Endpoint = custom.vpn.io:51820"));
|
||||||
|
|
||||||
|
// 9. Overlapping server-side AllowedIPs rejection
|
||||||
|
let overlap_peer = serde_json::json!({
|
||||||
|
"name": "overlapping-peer",
|
||||||
|
"peer_type": "road_warrior",
|
||||||
|
"address_v4": "10.0.0.2/32"
|
||||||
|
});
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/interfaces/{}/peers", interface.id))
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&overlap_peer).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
err_json["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("overlaps with active peer")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ 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, Peer, PeerProfile, PeerState, PeerType};
|
||||||
use nx9_wg_core::validation::validate_cidr;
|
use nx9_wg_core::validation::validate_cidr;
|
||||||
use nx9_wg_db::Store;
|
use nx9_wg_db::Store;
|
||||||
use nx9_wg_network::SimulatedNetworkEngine;
|
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||||
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tempfile::{TempDir, tempdir};
|
use tempfile::{TempDir, tempdir};
|
||||||
@@ -399,3 +399,187 @@ async fn test_reconciliation_status_lifecycle_and_multi_cycle_idempotency() {
|
|||||||
assert!(!plan.has_drift, "Cycle {cycle} plan must show zero drift");
|
assert!(!plan.has_drift, "Cycle {cycle} plan must show zero drift");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reconciliation_report_schema_and_json_contract() {
|
||||||
|
use nx9_wg_api::reconciliation::{ReconciliationReport, ReconciliationStatus};
|
||||||
|
|
||||||
|
let report = ReconciliationReport {
|
||||||
|
success: true,
|
||||||
|
status: ReconciliationStatus::Converged,
|
||||||
|
executed_actions: 3,
|
||||||
|
failed_actions: 0,
|
||||||
|
details: vec![
|
||||||
|
"Synchronized interface 'wg0' with 5 peers".to_string(),
|
||||||
|
"Synchronized 1 routing entries".to_string(),
|
||||||
|
"Synchronized 0 firewall rules into table inet nx9_wg (NAT: true)".to_string(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let json_val = serde_json::to_value(&report).unwrap();
|
||||||
|
assert_eq!(json_val["success"], true);
|
||||||
|
assert_eq!(json_val["status"], "converged");
|
||||||
|
assert_eq!(json_val["executed_actions"], 3);
|
||||||
|
assert_eq!(json_val["failed_actions"], 0);
|
||||||
|
assert!(json_val["details"].is_array());
|
||||||
|
assert_eq!(json_val["details"].as_array().unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reconciliation_nftables_canonical_drift_and_kernel_handle_tolerance() {
|
||||||
|
let (_dir, store, _state, _wg_engine, net_engine, reconciler) = setup_test_env().await;
|
||||||
|
|
||||||
|
// Add firewall rule in SQLite
|
||||||
|
let fw = FirewallRule {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "allow-https".to_string(),
|
||||||
|
interface_id: None,
|
||||||
|
peer_id: None,
|
||||||
|
direction: FirewallDirection::In,
|
||||||
|
source: None,
|
||||||
|
destination: None,
|
||||||
|
protocol: FirewallProtocol::Tcp,
|
||||||
|
source_port: None,
|
||||||
|
destination_port: Some(443),
|
||||||
|
port_range: None,
|
||||||
|
action: FirewallAction::Accept,
|
||||||
|
priority: 50,
|
||||||
|
enabled: true,
|
||||||
|
description: None,
|
||||||
|
created_at: Utc::now().naive_utc(),
|
||||||
|
updated_at: Utc::now().naive_utc(),
|
||||||
|
};
|
||||||
|
store.create_firewall_rule(&fw).await.unwrap();
|
||||||
|
|
||||||
|
// 1. Initial Plan should detect drift
|
||||||
|
let plan = reconciler.plan().await.unwrap();
|
||||||
|
assert!(plan.has_drift);
|
||||||
|
assert_eq!(plan.firewall_changes, 1);
|
||||||
|
|
||||||
|
// 2. Apply should converge
|
||||||
|
let report = reconciler.apply().await.unwrap();
|
||||||
|
assert!(report.success);
|
||||||
|
assert_eq!(
|
||||||
|
report.status,
|
||||||
|
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||||
|
);
|
||||||
|
assert_eq!(report.failed_actions, 0);
|
||||||
|
|
||||||
|
// 3. Post-apply verify: exactly 0 drift
|
||||||
|
let plan_after = reconciler.plan().await.unwrap();
|
||||||
|
assert!(!plan_after.has_drift);
|
||||||
|
assert_eq!(plan_after.firewall_changes, 0);
|
||||||
|
|
||||||
|
// 4. Simulate kernel returning ruleset with handles and tabs
|
||||||
|
let simulated_kernel_output_with_handles = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy accept;
|
||||||
|
ct state established,related accept # handle 46
|
||||||
|
iifname "lo" accept # handle 1
|
||||||
|
tcp dport 443 accept # handle 10
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority filter; policy accept;
|
||||||
|
ct state established,related accept # handle 4
|
||||||
|
}
|
||||||
|
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
// Set simulated ruleset to text containing kernel handles
|
||||||
|
net_engine
|
||||||
|
.sync_firewall(std::slice::from_ref(&fw), false, &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Directly test drift function against simulated kernel handles
|
||||||
|
let expected = nx9_wg_network::NftablesRulesetBuilder::build(&[fw], false, &[]);
|
||||||
|
assert!(
|
||||||
|
!nx9_wg_network::has_nftables_drift(&expected, simulated_kernel_output_with_handles),
|
||||||
|
"Ruleset with handles must not trigger false drift"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_interface_address_and_mtu_drift_lifecycle() {
|
||||||
|
let (_dir, store, _state, wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
||||||
|
|
||||||
|
let (priv_key, pub_key) = generate_keypair();
|
||||||
|
let iface_id = Uuid::new_v4();
|
||||||
|
let iface = Interface {
|
||||||
|
id: iface_id,
|
||||||
|
name: "wg0".to_string(),
|
||||||
|
private_key: priv_key,
|
||||||
|
public_key: pub_key.clone(),
|
||||||
|
listen_port: 51820,
|
||||||
|
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
|
||||||
|
address_v6: Some(validate_cidr("fd00::1/64").unwrap()),
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
store.create_interface(&iface).await.unwrap();
|
||||||
|
|
||||||
|
// 1. Initially, interface does not exist in wg_engine -> plan reports create_interface drift
|
||||||
|
let plan = reconciler.plan().await.unwrap();
|
||||||
|
assert!(plan.has_drift);
|
||||||
|
assert_eq!(plan.interface_changes, 1);
|
||||||
|
assert_eq!(plan.actions[0].action_type, "create_interface");
|
||||||
|
|
||||||
|
// 2. Apply initial sync -> interface is created and synchronized
|
||||||
|
let report = reconciler.apply().await.unwrap();
|
||||||
|
assert!(report.success);
|
||||||
|
assert_eq!(
|
||||||
|
report.status,
|
||||||
|
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Post-apply plan must have 0 drift
|
||||||
|
let plan_after = reconciler.plan().await.unwrap();
|
||||||
|
assert!(!plan_after.has_drift);
|
||||||
|
assert_eq!(plan_after.interface_changes, 0);
|
||||||
|
|
||||||
|
// 4. Manually strip IPv4 address from live interface to simulate kernel address drop
|
||||||
|
let mut stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap();
|
||||||
|
stats.addresses = vec!["fd00::1/64".to_string()]; // IPv4 missing
|
||||||
|
// Sync altered stats
|
||||||
|
wg_engine
|
||||||
|
.sync_interface(
|
||||||
|
&Interface {
|
||||||
|
address_v4: validate_cidr("10.99.99.99/24").unwrap(), // different
|
||||||
|
..iface.clone()
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 5. Plan MUST detect the missing/mismatched IPv4 address as drift
|
||||||
|
let plan_drift = reconciler.plan().await.unwrap();
|
||||||
|
assert!(plan_drift.has_drift);
|
||||||
|
assert_eq!(plan_drift.interface_changes, 1);
|
||||||
|
assert_eq!(plan_drift.actions[0].action_type, "update_interface");
|
||||||
|
assert!(plan_drift.actions[0].description.contains("IPv4 address"));
|
||||||
|
|
||||||
|
// 6. Apply reconciliation -> restores correct addresses
|
||||||
|
let report2 = reconciler.apply().await.unwrap();
|
||||||
|
assert!(report2.success);
|
||||||
|
assert_eq!(
|
||||||
|
report2.status,
|
||||||
|
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Final plan reports 0 drift
|
||||||
|
let final_plan = reconciler.plan().await.unwrap();
|
||||||
|
assert!(!final_plan.has_drift);
|
||||||
|
assert_eq!(final_plan.interface_changes, 0);
|
||||||
|
}
|
||||||
@@ -234,3 +234,178 @@ async fn test_networks_and_firewall_rest_lifecycle() {
|
|||||||
assert_eq!(rule_val["name"], "Allow HTTPS");
|
assert_eq!(rule_val["name"], "Allow HTTPS");
|
||||||
assert_eq!(rule_val["priority"], 10);
|
assert_eq!(rule_val["priority"], 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_all_peers_collection_endpoint() {
|
||||||
|
let (app, cookie) = setup_test_app().await;
|
||||||
|
|
||||||
|
// 1. Verify unauthenticated GET /api/v1/peers returns 401 Unauthorized
|
||||||
|
let unauth_req = Request::builder()
|
||||||
|
.uri("/api/v1/peers")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let unauth_resp = app.clone().oneshot(unauth_req).await.unwrap();
|
||||||
|
assert_eq!(unauth_resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
// 2. Create first interface (wg0)
|
||||||
|
let iface0_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 iface0_resp = app.clone().oneshot(iface0_req).await.unwrap();
|
||||||
|
assert_eq!(iface0_resp.status(), StatusCode::OK);
|
||||||
|
let iface0_val: Value =
|
||||||
|
serde_json::from_slice(&to_bytes(iface0_resp.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let iface0_id = iface0_val["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// 3. Create second interface (wg1)
|
||||||
|
let iface1_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/interfaces")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "wg1",
|
||||||
|
"listen_port": 51821,
|
||||||
|
"address_v4": "10.200.0.1/24"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let iface1_resp = app.clone().oneshot(iface1_req).await.unwrap();
|
||||||
|
assert_eq!(iface1_resp.status(), StatusCode::OK);
|
||||||
|
let iface1_val: Value =
|
||||||
|
serde_json::from_slice(&to_bytes(iface1_resp.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let iface1_id = iface1_val["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// 4. Create 2 peers under wg0
|
||||||
|
let peer_alice_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "peer-alice",
|
||||||
|
"allowed_ips": "10.100.0.2/32"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp_alice = app.clone().oneshot(peer_alice_req).await.unwrap();
|
||||||
|
assert_eq!(resp_alice.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let peer_bob_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "peer-bob",
|
||||||
|
"allowed_ips": "10.100.0.3/32"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp_bob = app.clone().oneshot(peer_bob_req).await.unwrap();
|
||||||
|
assert_eq!(resp_bob.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 5. Create 1 peer under wg1
|
||||||
|
let peer_charlie_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "peer-charlie",
|
||||||
|
"allowed_ips": "10.200.0.2/32"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp_charlie = app.clone().oneshot(peer_charlie_req).await.unwrap();
|
||||||
|
assert_eq!(resp_charlie.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 6. Test GET /api/v1/peers (All peers across all interfaces)
|
||||||
|
let list_all_req = Request::builder()
|
||||||
|
.uri("/api/v1/peers")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let list_all_resp = app.clone().oneshot(list_all_req).await.unwrap();
|
||||||
|
assert_eq!(list_all_resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let all_peers_bytes = to_bytes(list_all_resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let all_peers: Vec<Value> = serde_json::from_slice(&all_peers_bytes).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
all_peers.len(),
|
||||||
|
3,
|
||||||
|
"GET /api/v1/peers must return all 3 peers across both interfaces"
|
||||||
|
);
|
||||||
|
|
||||||
|
let peer_names: Vec<&str> = all_peers
|
||||||
|
.iter()
|
||||||
|
.map(|p| p["name"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(peer_names.contains(&"peer-alice"));
|
||||||
|
assert!(peer_names.contains(&"peer-bob"));
|
||||||
|
assert!(peer_names.contains(&"peer-charlie"));
|
||||||
|
|
||||||
|
// Verify representative fields are present and valid
|
||||||
|
for p in &all_peers {
|
||||||
|
assert!(p["id"].is_string());
|
||||||
|
assert!(p["public_key"].is_string());
|
||||||
|
assert!(p["interface_id"].is_string());
|
||||||
|
assert!(p["state"].is_string());
|
||||||
|
assert!(p["allowed_ips"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Verify interface-scoped endpoint still works and returns only that interface's peers
|
||||||
|
let list_iface0_req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let iface0_peers_resp = app.clone().oneshot(list_iface0_req).await.unwrap();
|
||||||
|
assert_eq!(iface0_peers_resp.status(), StatusCode::OK);
|
||||||
|
let iface0_peers: Vec<Value> = serde_json::from_slice(
|
||||||
|
&to_bytes(iface0_peers_resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(iface0_peers.len(), 2, "wg0 must return exactly 2 peers");
|
||||||
|
|
||||||
|
let list_iface1_req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let iface1_peers_resp = app.oneshot(list_iface1_req).await.unwrap();
|
||||||
|
assert_eq!(iface1_peers_resp.status(), StatusCode::OK);
|
||||||
|
let iface1_peers: Vec<Value> = serde_json::from_slice(
|
||||||
|
&to_bytes(iface1_peers_resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(iface1_peers.len(), 1, "wg1 must return exactly 1 peer");
|
||||||
|
assert_eq!(iface1_peers[0]["name"], "peer-charlie");
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
//! Comprehensive tests for WireGuard road-warrior data-plane, cryptokey routing,
|
||||||
|
//! endpoint resolution, telemetry ingestion, and reconciliation invariants.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use chrono::Utc;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
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_db::Store;
|
||||||
|
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||||
|
use nx9_wireguard::{
|
||||||
|
ClientConfigBuilder, LiveInterfaceStats, LivePeerStats, SimulatedWireGuardEngine,
|
||||||
|
WireGuardEngine,
|
||||||
|
};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
async fn setup_test_context() -> (AppState, Interface, Peer, String) {
|
||||||
|
let store = Store::connect_in_memory().await.unwrap();
|
||||||
|
store.migrate().await.unwrap();
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let hash = nx9_wg_core::crypto::hash_password("testadminpass123").unwrap();
|
||||||
|
store.create_admin("admin", &hash).await.unwrap();
|
||||||
|
|
||||||
|
let session = nx9_wg_core::types::auth::Session {
|
||||||
|
id: "test-dataplane-session-id".to_string(),
|
||||||
|
admin_id: 1,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: now + chrono::Duration::hours(24),
|
||||||
|
last_seen_at: Some(now),
|
||||||
|
ip_address: Some("127.0.0.1".to_string()),
|
||||||
|
user_agent: Some("test-agent".to_string()),
|
||||||
|
};
|
||||||
|
store.create_session(&session).await.unwrap();
|
||||||
|
|
||||||
|
let (srv_priv, srv_pub) = generate_keypair();
|
||||||
|
let (peer_priv, peer_pub) = generate_keypair();
|
||||||
|
|
||||||
|
let interface = Interface {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "wg0".to_string(),
|
||||||
|
private_key: srv_priv,
|
||||||
|
public_key: srv_pub,
|
||||||
|
listen_port: 51820,
|
||||||
|
address_v4: IpNet::from_str("10.100.0.1/24").unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: Some(1420),
|
||||||
|
dns: Some("1.1.1.1, 1.0.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.create_interface(&interface).await.unwrap();
|
||||||
|
|
||||||
|
let peer = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: interface.id,
|
||||||
|
name: "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.0.9/32").unwrap()),
|
||||||
|
address_v6: None,
|
||||||
|
dns: Some("1.1.1.1, 1.0.0.1".to_string()),
|
||||||
|
mtu: Some(1420),
|
||||||
|
persistent_keepalive: Some(25),
|
||||||
|
profile: PeerProfile::FullTunnel,
|
||||||
|
expires_at: None,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
store.create_peer(&peer).await.unwrap();
|
||||||
|
|
||||||
|
let state = AppState::new(store);
|
||||||
|
(state, interface, peer, session.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_road_warrior_server_allowed_ips_vs_client_full_tunnel() {
|
||||||
|
let (_state, iface, peer, _session_id) = setup_test_context().await;
|
||||||
|
|
||||||
|
// 1. Server-side WireGuard peer AllowedIPs MUST be strictly the assigned client IP (10.100.0.9/32)
|
||||||
|
assert_eq!(peer.server_wireguard_allowed_ips(), "10.100.0.9/32");
|
||||||
|
|
||||||
|
// 2. Client configuration MUST contain the FullTunnel routing policy (0.0.0.0/0 for IPv4-only server)
|
||||||
|
let conf = ClientConfigBuilder::build(&peer, &iface, "192.168.1.8:51820").unwrap();
|
||||||
|
assert!(conf.contains("Address = 10.100.0.9/32"));
|
||||||
|
assert!(conf.contains("AllowedIPs = 0.0.0.0/0"));
|
||||||
|
assert!(conf.contains("Endpoint = 192.168.1.8:51820"));
|
||||||
|
assert!(conf.contains("PersistentKeepalive = 25"));
|
||||||
|
|
||||||
|
// Dual-stack interface exports dual-stack full tunnel
|
||||||
|
let mut dual_iface = iface.clone();
|
||||||
|
dual_iface.address_v6 = Some(IpNet::from_str("fd00::1/64").unwrap());
|
||||||
|
let dual_conf = ClientConfigBuilder::build(&peer, &dual_iface, "192.168.1.8:51820").unwrap();
|
||||||
|
assert!(dual_conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_endpoint_resolution_failure_and_override() {
|
||||||
|
let (state, _iface, peer, session_id) = setup_test_context().await;
|
||||||
|
let app = build_api_router(state.clone());
|
||||||
|
|
||||||
|
// 1. Config export without persistent setting or query endpoint fails with 422
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
err_json["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("No reachable WireGuard server endpoint is configured")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Setting persistent server_endpoint setting succeeds
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.set_setting("server_endpoint", "192.168.1.8:51820", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/peers/{}/config", peer.id))
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let conf_str = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(conf_str.contains("Endpoint = 192.168.1.8:51820"));
|
||||||
|
|
||||||
|
// 3. Explicit query parameter overrides persistent setting
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/api/v1/peers/{}/config?server_endpoint=vpn.publicdomain.org:51820",
|
||||||
|
peer.id
|
||||||
|
))
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let conf_str = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(conf_str.contains("Endpoint = vpn.publicdomain.org:51820"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_learned_endpoint_and_handshake_telemetry_ingestion() {
|
||||||
|
let (state, iface, peer, _session_id) = setup_test_context().await;
|
||||||
|
|
||||||
|
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||||
|
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||||
|
|
||||||
|
// Sync initial state to simulated engine
|
||||||
|
wg_engine
|
||||||
|
.sync_interface(&iface, std::slice::from_ref(&peer))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let reconciler =
|
||||||
|
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||||
|
|
||||||
|
// Initially peer has no learned endpoint or handshake in DB
|
||||||
|
let p_db = state.store.get_peer(peer.id).await.unwrap().unwrap();
|
||||||
|
assert!(p_db.endpoint.is_none());
|
||||||
|
assert!(p_db.last_handshake_at.is_none());
|
||||||
|
|
||||||
|
// Simulate incoming authenticated handshake from client
|
||||||
|
let hs_time = Utc::now().naive_utc();
|
||||||
|
let learned_client_ep = "192.168.1.50:41234".to_string();
|
||||||
|
|
||||||
|
// Apply initial baseline state to ensure all subsystems start converged
|
||||||
|
let initial_report = reconciler.apply().await.unwrap();
|
||||||
|
assert!(initial_report.success);
|
||||||
|
|
||||||
|
// Directly simulate kernel stats containing learned endpoint and handshake
|
||||||
|
let live_peers = vec![LivePeerStats {
|
||||||
|
public_key: peer.public_key.as_str().to_string(),
|
||||||
|
endpoint: Some(learned_client_ep.clone()),
|
||||||
|
rx_bytes: 1024,
|
||||||
|
tx_bytes: 2048,
|
||||||
|
last_handshake_at: Some(hs_time),
|
||||||
|
allowed_ips: vec!["10.100.0.9/32".to_string()],
|
||||||
|
persistent_keepalive: Some(25),
|
||||||
|
}];
|
||||||
|
wg_engine
|
||||||
|
.inject_interface_stats(LiveInterfaceStats {
|
||||||
|
name: iface.name.clone(),
|
||||||
|
public_key: iface.public_key.as_str().to_string(),
|
||||||
|
listen_port: iface.listen_port,
|
||||||
|
fwmark: 0,
|
||||||
|
peers: live_peers,
|
||||||
|
addresses: vec!["10.100.0.1/24".to_string()],
|
||||||
|
mtu: Some(1420),
|
||||||
|
is_up: true,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Run reconciliation plan — should ingest telemetry without peer drift
|
||||||
|
let plan = reconciler.plan().await.unwrap();
|
||||||
|
assert_eq!(plan.peer_changes, 0);
|
||||||
|
|
||||||
|
// Verify learned telemetry was ingested into the SQLite store
|
||||||
|
let updated_peer = state.store.get_peer(peer.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(updated_peer.endpoint.as_deref(), Some("192.168.1.50:41234"));
|
||||||
|
assert_eq!(
|
||||||
|
updated_peer
|
||||||
|
.last_handshake_at
|
||||||
|
.unwrap()
|
||||||
|
.and_utc()
|
||||||
|
.timestamp(),
|
||||||
|
hs_time.and_utc().timestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_peer_allowed_ips_and_keepalive_kernel_drift() {
|
||||||
|
let (state, iface, peer, _session_id) = setup_test_context().await;
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
// Inject drift: kernel peer erroneously has 0.0.0.0/0 as AllowedIPs and keepalive = 10s
|
||||||
|
let drifted_peers = vec![LivePeerStats {
|
||||||
|
public_key: peer.public_key.as_str().to_string(),
|
||||||
|
endpoint: None,
|
||||||
|
rx_bytes: 0,
|
||||||
|
tx_bytes: 0,
|
||||||
|
last_handshake_at: None,
|
||||||
|
allowed_ips: vec!["0.0.0.0/0".to_string(), "::/0".to_string()],
|
||||||
|
persistent_keepalive: Some(10),
|
||||||
|
}];
|
||||||
|
wg_engine
|
||||||
|
.inject_interface_stats(LiveInterfaceStats {
|
||||||
|
name: iface.name.clone(),
|
||||||
|
public_key: iface.public_key.as_str().to_string(),
|
||||||
|
listen_port: iface.listen_port,
|
||||||
|
fwmark: 0,
|
||||||
|
peers: drifted_peers,
|
||||||
|
addresses: vec!["10.100.0.1/24".to_string()],
|
||||||
|
mtu: Some(1420),
|
||||||
|
is_up: true,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Reconciliation plan MUST detect this semantic drift
|
||||||
|
let plan = reconciler.plan().await.unwrap();
|
||||||
|
assert!(plan.has_drift);
|
||||||
|
assert_eq!(plan.peer_changes, 1);
|
||||||
|
assert!(
|
||||||
|
plan.actions
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.action_type == "update_peer" && a.description.contains("AllowedIPs drift"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute reconciliation apply
|
||||||
|
let report = reconciler.apply().await.unwrap();
|
||||||
|
assert!(report.success);
|
||||||
|
assert_eq!(
|
||||||
|
report.status,
|
||||||
|
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify post-apply convergence: zero drift
|
||||||
|
let post_plan = reconciler.plan().await.unwrap();
|
||||||
|
assert!(!post_plan.has_drift);
|
||||||
|
assert_eq!(post_plan.peer_changes, 0);
|
||||||
|
|
||||||
|
// Verify live kernel stats now match desired server AllowedIPs (10.100.0.9/32)
|
||||||
|
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap();
|
||||||
|
assert_eq!(live_stats.peers[0].allowed_ips, vec!["10.100.0.9/32"]);
|
||||||
|
assert_eq!(live_stats.peers[0].persistent_keepalive, Some(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_forwarding_and_nat_reconciliation_invariants() {
|
||||||
|
let (state, _iface, _peer, _session_id) = setup_test_context().await;
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
// Enable NAT masquerade in settings
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.set_setting("nat_enabled", "true", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Reconcile apply
|
||||||
|
let report = reconciler.apply().await.unwrap();
|
||||||
|
assert!(report.success);
|
||||||
|
assert_eq!(
|
||||||
|
report.status,
|
||||||
|
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify NAT masquerade is active in network engine for 10.100.0.0/24 subnet
|
||||||
|
let ruleset = net_engine.get_active_nftables_ruleset().await.unwrap();
|
||||||
|
assert!(ruleset.contains("masquerade"));
|
||||||
|
assert!(ruleset.contains("10.100.0.0/24"));
|
||||||
|
|
||||||
|
// Re-planning shows 0 drift
|
||||||
|
let plan = reconciler.plan().await.unwrap();
|
||||||
|
assert!(!plan.has_drift);
|
||||||
|
assert_eq!(plan.firewall_changes, 0);
|
||||||
|
assert_eq!(plan.route_changes, 0);
|
||||||
|
assert_eq!(plan.interface_changes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_interface_editing_persistence_and_key_preservation() {
|
||||||
|
let (state, iface, _peer, session_id) = setup_test_context().await;
|
||||||
|
let app = build_api_router(state.clone());
|
||||||
|
|
||||||
|
let orig_priv_key = iface.private_key.clone();
|
||||||
|
let orig_pub_key = iface.public_key.clone();
|
||||||
|
let orig_id = iface.id;
|
||||||
|
|
||||||
|
// 1. Edit interface wg0 (change address_v4, listen_port, MTU, DNS, enabled)
|
||||||
|
let update_req = Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri(format!("/api/v1/interfaces/{}", iface.id))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "wg0",
|
||||||
|
"address_v4": "10.200.0.1/24",
|
||||||
|
"listen_port": 51822,
|
||||||
|
"mtu": 1360,
|
||||||
|
"dns": "9.9.9.9",
|
||||||
|
"enabled": true
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(update_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 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.mtu, Some(1360));
|
||||||
|
assert_eq!(updated_iface.dns, Some("9.9.9.9".to_string()));
|
||||||
|
|
||||||
|
// 3. Verify private key, public key, and ID were strictly preserved (NEVER regenerated)
|
||||||
|
assert_eq!(updated_iface.id, orig_id);
|
||||||
|
assert_eq!(updated_iface.private_key.as_str(), orig_priv_key.as_str());
|
||||||
|
assert_eq!(updated_iface.public_key.as_str(), orig_pub_key.as_str());
|
||||||
|
|
||||||
|
// 4. Verify peers attached to wg0 were preserved
|
||||||
|
let peers = state.store.list_peers_for_interface(orig_id).await.unwrap();
|
||||||
|
assert_eq!(peers.len(), 1);
|
||||||
|
assert_eq!(peers[0].name, "Mobile");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_server_endpoint_persistence_validation_and_export_precedence() {
|
||||||
|
let (state, _iface, peer, session_id) = setup_test_context().await;
|
||||||
|
let app = build_api_router(state.clone());
|
||||||
|
|
||||||
|
// 1. Invalid server_endpoint format (missing port) is rejected with 422
|
||||||
|
let invalid_setting_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": "server_endpoint",
|
||||||
|
"value": "192.168.1.8", // missing port!
|
||||||
|
"is_secret": false
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(invalid_setting_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
// 2. Valid server_endpoint saves successfully
|
||||||
|
let valid_setting_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": "server_endpoint",
|
||||||
|
"value": "192.168.1.8:51820",
|
||||||
|
"is_secret": false
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(valid_setting_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 3. Export config without override consumes the persisted setting automatically
|
||||||
|
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 = 192.168.1.8:51820"));
|
||||||
|
|
||||||
|
// 4. Export QR code returns JSON with SVG and data_url containing the same endpoint
|
||||||
|
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);
|
||||||
|
let qr_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let qr_json: serde_json::Value = serde_json::from_slice(&qr_bytes).unwrap();
|
||||||
|
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
|
||||||
|
assert!(
|
||||||
|
qr_json["data_url"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("data:image/png;base64,")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Explicit override query parameter takes precedence over setting
|
||||||
|
let override_req = Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/api/v1/peers/{}/config?endpoint=vpn.wan-domain.org:51820",
|
||||||
|
peer.id
|
||||||
|
))
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(override_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.wan-domain.org:51820"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
||||||
|
let (state_orig, iface, peer, session_id) = setup_test_context().await;
|
||||||
|
|
||||||
|
let simulated_wg = Arc::new(SimulatedWireGuardEngine::new());
|
||||||
|
let simulated_net = Arc::new(SimulatedNetworkEngine::new());
|
||||||
|
let state = AppState::with_engines(
|
||||||
|
state_orig.store.clone(),
|
||||||
|
simulated_wg.clone(),
|
||||||
|
simulated_net.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inject live kernel statistics into simulated WireGuard engine
|
||||||
|
let recent_hs = Utc::now().naive_utc() - chrono::Duration::seconds(15);
|
||||||
|
let live_peer = LivePeerStats {
|
||||||
|
public_key: peer.public_key.to_string(),
|
||||||
|
endpoint: Some("192.168.1.50:41234".to_string()),
|
||||||
|
rx_bytes: 409600,
|
||||||
|
tx_bytes: 819200,
|
||||||
|
last_handshake_at: Some(recent_hs),
|
||||||
|
allowed_ips: vec!["10.100.0.9/32".to_string()],
|
||||||
|
persistent_keepalive: Some(25),
|
||||||
|
};
|
||||||
|
|
||||||
|
let live_iface = LiveInterfaceStats {
|
||||||
|
name: iface.name.clone(),
|
||||||
|
public_key: iface.public_key.to_string(),
|
||||||
|
listen_port: iface.listen_port,
|
||||||
|
fwmark: 0,
|
||||||
|
peers: vec![live_peer],
|
||||||
|
addresses: vec!["10.100.0.1/24".to_string()],
|
||||||
|
mtu: Some(1420),
|
||||||
|
is_up: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inject live stats into simulated_wg
|
||||||
|
simulated_wg.inject_interface_stats(live_iface).await;
|
||||||
|
|
||||||
|
let app = build_api_router(state.clone());
|
||||||
|
|
||||||
|
// Query GET /api/v1/peers
|
||||||
|
let list_req = Request::builder()
|
||||||
|
.uri("/api/v1/peers")
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let peers_json: Vec<serde_json::Value> = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
assert_eq!(peers_json.len(), 1);
|
||||||
|
|
||||||
|
let p = &peers_json[0];
|
||||||
|
assert_eq!(p["name"], "Mobile");
|
||||||
|
assert_eq!(p["endpoint"], "192.168.1.50:41234");
|
||||||
|
assert_eq!(p["rx_bytes"], 409600);
|
||||||
|
assert_eq!(p["tx_bytes"], 819200);
|
||||||
|
|
||||||
|
// Handshake is serialized as explicit RFC3339 UTC string with offset/Z
|
||||||
|
let hs_str = p["last_handshake_at"].as_str().unwrap();
|
||||||
|
assert!(hs_str.contains('T'));
|
||||||
|
assert!(hs_str.ends_with('Z') || hs_str.contains("+00:00"));
|
||||||
|
|
||||||
|
// Verify learned telemetry was cached in SQLite
|
||||||
|
let db_peer = state.store.get_peer(peer.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(db_peer.endpoint, Some("192.168.1.50:41234".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
db_peer.last_handshake_at.unwrap().and_utc().timestamp(),
|
||||||
|
recent_hs.and_utc().timestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,24 @@ use nx9_wg_api::state::AppState;
|
|||||||
use nx9_wg_db::Store;
|
use nx9_wg_db::Store;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn setup_test_app() -> (axum::Router, Store) {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect store");
|
||||||
|
store.migrate().await.expect("migrate store");
|
||||||
|
|
||||||
|
let config = nx9_wg_core::config::AppConfig::default();
|
||||||
|
let opts = nx9_wg_api::auth::BootstrapOptions {
|
||||||
|
cli_password: Some("TestAdminPassword123!".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
nx9_wg_api::auth::bootstrap_admin(&store, &config, &opts)
|
||||||
|
.await
|
||||||
|
.expect("bootstrap admin");
|
||||||
|
|
||||||
|
let state = AppState::new(store.clone());
|
||||||
|
let app = build_api_router(state);
|
||||||
|
(app, store)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
||||||
let store = Store::connect_in_memory().await.expect("connect store");
|
let store = Store::connect_in_memory().await.expect("connect store");
|
||||||
@@ -121,6 +139,11 @@ async fn test_ui_api_complete_functional_loop() {
|
|||||||
.await
|
.await
|
||||||
.expect("bootstrap admin");
|
.expect("bootstrap admin");
|
||||||
|
|
||||||
|
store
|
||||||
|
.set_setting("server_endpoint", "vpn.example.com", false)
|
||||||
|
.await
|
||||||
|
.expect("set server_endpoint");
|
||||||
|
|
||||||
let state = AppState::new(store.clone());
|
let state = AppState::new(store.clone());
|
||||||
let app = build_api_router(state);
|
let app = build_api_router(state);
|
||||||
|
|
||||||
@@ -227,6 +250,26 @@ async fn test_ui_api_complete_functional_loop() {
|
|||||||
let peer_json: serde_json::Value = serde_json::from_slice(&peer_body).unwrap();
|
let peer_json: serde_json::Value = serde_json::from_slice(&peer_body).unwrap();
|
||||||
let peer_id = peer_json["id"].as_str().unwrap();
|
let peer_id = peer_json["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// 4b. UI fetches collection of all peers (Peers page render: GET /api/v1/peers)
|
||||||
|
let res_all_peers = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/v1/peers")
|
||||||
|
.header(axum::http::header::COOKIE, &session_cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("get all peers");
|
||||||
|
assert_eq!(res_all_peers.status(), StatusCode::OK);
|
||||||
|
let all_peers_bytes = to_bytes(res_all_peers.into_body(), 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let all_peers_json: Vec<serde_json::Value> = serde_json::from_slice(&all_peers_bytes).unwrap();
|
||||||
|
assert_eq!(all_peers_json.len(), 1);
|
||||||
|
assert_eq!(all_peers_json[0]["name"], "alice-phone");
|
||||||
|
|
||||||
// 5. UI downloads Client Config & SVG QR Code
|
// 5. UI downloads Client Config & SVG QR Code
|
||||||
let res_conf = app
|
let res_conf = app
|
||||||
.clone()
|
.clone()
|
||||||
@@ -452,6 +495,7 @@ async fn test_ui_api_complete_functional_loop() {
|
|||||||
|
|
||||||
// 13. UI Logout
|
// 13. UI Logout
|
||||||
let res_logout = app
|
let res_logout = app
|
||||||
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
@@ -463,4 +507,205 @@ async fn test_ui_api_complete_functional_loop() {
|
|||||||
.await
|
.await
|
||||||
.expect("logout request");
|
.expect("logout request");
|
||||||
assert_eq!(res_logout.status(), StatusCode::OK);
|
assert_eq!(res_logout.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 14. Post-Logout: Session must be completely rejected on protected endpoints
|
||||||
|
let res_post_logout = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/v1/auth/session")
|
||||||
|
.header(axum::http::header::COOKIE, &session_cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("post-logout session request");
|
||||||
|
assert_eq!(res_post_logout.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_logout_session_invalidation_and_idempotency() {
|
||||||
|
let (app, _store) = setup_test_app().await;
|
||||||
|
|
||||||
|
// 1. Initial login
|
||||||
|
let login_body = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "TestAdminPassword123!"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let res_login = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(axum::body::Body::from(login_body))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("login request");
|
||||||
|
assert_eq!(res_login.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let cookie_header = res_login
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::SET_COOKIE)
|
||||||
|
.expect("Set-Cookie header present")
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
let session_cookie = cookie_header
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.expect("nx9_session cookie")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 2. Verified access before logout
|
||||||
|
let res_auth_session = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/v1/auth/session")
|
||||||
|
.header(axum::http::header::COOKIE, &session_cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res_auth_session.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let res_system = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/v1/system")
|
||||||
|
.header(axum::http::header::COOKIE, &session_cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res_system.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 3. Perform Logout
|
||||||
|
let res_logout = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/logout")
|
||||||
|
.header(axum::http::header::COOKIE, &session_cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res_logout.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let logout_cookie = res_logout
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::SET_COOKIE)
|
||||||
|
.expect("Set-Cookie on logout")
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
logout_cookie.contains("Max-Age=0"),
|
||||||
|
"Logout must clear session cookie with Max-Age=0"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. All protected endpoints must return 401 Unauthorized after logout
|
||||||
|
let endpoints = [
|
||||||
|
"/api/v1/auth/session",
|
||||||
|
"/api/v1/system",
|
||||||
|
"/api/v1/interfaces",
|
||||||
|
"/api/v1/networks",
|
||||||
|
"/api/v1/routes",
|
||||||
|
"/api/v1/firewall/rules",
|
||||||
|
"/api/v1/diagnostics/all",
|
||||||
|
"/api/v1/client-profiles",
|
||||||
|
"/api/v1/audit",
|
||||||
|
"/api/v1/backups",
|
||||||
|
"/api/v1/reconcile/plan",
|
||||||
|
];
|
||||||
|
|
||||||
|
for ep in endpoints {
|
||||||
|
let res_blocked = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(ep)
|
||||||
|
.header(axum::http::header::COOKIE, &session_cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res_blocked.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Endpoint {ep} must be blocked (401) after logout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Repeated logout when already logged out is safe and idempotent
|
||||||
|
let res_logout_again = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/logout")
|
||||||
|
.header(axum::http::header::COOKIE, &session_cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res_logout_again.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_ui_index_contains_login_view_and_hidden_app_layout() {
|
||||||
|
let (app, _store) = setup_test_app().await;
|
||||||
|
|
||||||
|
let res = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("index request");
|
||||||
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let html = String::from_utf8(bytes.to_vec()).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
html.contains("id=\"login-view\""),
|
||||||
|
"HTML must contain dedicated login-view container"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("id=\"app-layout\" style=\"display: none;\""),
|
||||||
|
"app-layout must be initially hidden until authenticated"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("id=\"login-username\""),
|
||||||
|
"HTML must contain login username input"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("id=\"login-password\""),
|
||||||
|
"HTML must contain login password input"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("id=\"login-submit-btn\""),
|
||||||
|
"HTML must contain login submit button"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("handleLogout()"),
|
||||||
|
"HTML must contain handleLogout handler"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -215,9 +215,82 @@ pub struct Peer {
|
|||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Peer {
|
||||||
|
/// Returns the effective server-side WireGuard AllowedIPs string for this peer.
|
||||||
|
///
|
||||||
|
/// # Semantics:
|
||||||
|
/// - If `server_allowed_ips` is explicitly configured and non-empty, it is used after
|
||||||
|
/// validating CIDRs. For `RoadWarrior` peers, default full-tunnel routes (`0.0.0.0/0`, `::/0`)
|
||||||
|
/// are strictly forbidden as server-side AllowedIPs.
|
||||||
|
/// - For `RoadWarrior` peers: derives exclusively from the peer's assigned tunnel addresses
|
||||||
|
/// (`address_v4/32` and `address_v6/128`).
|
||||||
|
/// - For non-`RoadWarrior` peers (e.g. `SiteGateway`, `Server`, `Relay`): uses assigned tunnel
|
||||||
|
/// addresses or explicit subnets from `allowed_ips` (excluding `0.0.0.0/0` and `::/0`).
|
||||||
|
///
|
||||||
|
/// # Invariants:
|
||||||
|
/// - NEVER returns `0.0.0.0/0` or `::/0` as server-side AllowedIPs for a RoadWarrior peer.
|
||||||
|
/// - NEVER falls back to client full-tunnel routing policy.
|
||||||
|
pub fn server_wireguard_allowed_ips(&self) -> String {
|
||||||
|
// 1. Explicit server_allowed_ips override
|
||||||
|
if let Some(ref s_allowed) = self.server_allowed_ips {
|
||||||
|
let trimmed = s_allowed.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let mut valid_cidrs = Vec::new();
|
||||||
|
for item in trimmed.split(',') {
|
||||||
|
let item_trim = item.trim();
|
||||||
|
if let Ok(net) = item_trim.parse::<IpNet>() {
|
||||||
|
// For RoadWarrior, reject 0.0.0.0/0 or ::/0
|
||||||
|
if self.peer_type == PeerType::RoadWarrior && net.prefix_len() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
valid_cidrs.push(net.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid_cidrs.is_empty() {
|
||||||
|
return valid_cidrs.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Default for RoadWarrior (and default for any peer with assigned addresses):
|
||||||
|
// Derive exclusively from assigned tunnel addresses (/32 and /128)
|
||||||
|
let mut addrs = Vec::new();
|
||||||
|
if let Some(ref v4) = self.address_v4 {
|
||||||
|
addrs.push(format!("{}/32", v4.addr()));
|
||||||
|
}
|
||||||
|
if let Some(ref v6) = self.address_v6 {
|
||||||
|
addrs.push(format!("{}/128", v6.addr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !addrs.is_empty() {
|
||||||
|
return addrs.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. For non-RoadWarrior peers without assigned tunnel addresses (e.g. site gateway routing subnets),
|
||||||
|
// inspect allowed_ips, strictly excluding default 0.0.0.0/0 and ::/0
|
||||||
|
if self.peer_type != PeerType::RoadWarrior {
|
||||||
|
let mut valid_cidrs = Vec::new();
|
||||||
|
for item in self.allowed_ips.split(',') {
|
||||||
|
let item_trim = item.trim();
|
||||||
|
if let Ok(net) = item_trim.parse::<IpNet>()
|
||||||
|
&& net.prefix_len() > 0
|
||||||
|
{
|
||||||
|
valid_cidrs.push(net.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid_cidrs.is_empty() {
|
||||||
|
return valid_cidrs.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_peer_type_roundtrip() {
|
fn test_peer_type_roundtrip() {
|
||||||
@@ -233,4 +306,102 @@ mod tests {
|
|||||||
let pk = WireGuardPrivateKey::new("secret".to_string());
|
let pk = WireGuardPrivateKey::new("secret".to_string());
|
||||||
assert_eq!(format!("{:?}", pk), "[REDACTED]");
|
assert_eq!(format!("{:?}", pk), "[REDACTED]");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_road_warrior_server_allowed_ips_derives_from_assigned_address() {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let peer = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: Uuid::new_v4(),
|
||||||
|
name: "Mobile".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new("pubkey123".to_string()),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "0.0.0.0/0, ::/0".to_string(), // Client full tunnel routing policy
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: Some("10.100.0.9/24".parse().unwrap()),
|
||||||
|
address_v6: Some("fd00::9/64".parse().unwrap()),
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: Some(25),
|
||||||
|
profile: PeerProfile::FullTunnel,
|
||||||
|
expires_at: None,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-side AllowedIPs MUST be 10.100.0.9/32, fd00::9/128 (never 0.0.0.0/0)
|
||||||
|
assert_eq!(
|
||||||
|
peer.server_wireguard_allowed_ips(),
|
||||||
|
"10.100.0.9/32, fd00::9/128"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_road_warrior_explicit_server_allowed_ips_override() {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let peer = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: Uuid::new_v4(),
|
||||||
|
name: "Mobile".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new("pubkey123".to_string()),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||||
|
server_allowed_ips: Some("10.100.0.9/32, 192.168.50.0/24".to_string()),
|
||||||
|
address_v4: Some("10.100.0.9/32".parse().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,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
peer.server_wireguard_allowed_ips(),
|
||||||
|
"10.100.0.9/32, 192.168.50.0/24"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_road_warrior_forbids_default_route_as_server_allowed_ips() {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let peer = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: Uuid::new_v4(),
|
||||||
|
name: "Mobile".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new("pubkey123".to_string()),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||||
|
server_allowed_ips: Some("0.0.0.0/0".to_string()), // Attempt to set full tunnel as server allowed ips
|
||||||
|
address_v4: Some("10.100.0.9/32".parse().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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rejects 0.0.0.0/0 and falls back to assigned address 10.100.0.9/32
|
||||||
|
assert_eq!(peer.server_wireguard_allowed_ips(), "10.100.0.9/32");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +146,27 @@ pub fn validate_ip_in_network(ip: IpAddr, net: IpNet) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate a comma-separated list of CIDR subnets (e.g. "10.100.0.9/32, 192.168.1.0/24").
|
||||||
|
pub fn validate_allowed_ips_cidr_list(list: &str) -> Result<Vec<IpNet>> {
|
||||||
|
let mut nets = Vec::new();
|
||||||
|
for item in list.split(',') {
|
||||||
|
let trimmed = item.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let net = validate_cidr(trimmed)?;
|
||||||
|
if !nets.contains(&net) {
|
||||||
|
nets.push(net);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nets.is_empty() {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"AllowedIPs list cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(nets)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate port.
|
/// Validate port.
|
||||||
pub fn validate_port(port: u16) -> Result<u16> {
|
pub fn validate_port(port: u16) -> Result<u16> {
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
|
|||||||
@@ -349,6 +349,49 @@ pub async fn update_peer_handshake(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update operational telemetry (learned remote endpoint and last handshake timestamp) from kernel.
|
||||||
|
pub async fn update_peer_learned_telemetry(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: Uuid,
|
||||||
|
handshake_at: Option<NaiveDateTime>,
|
||||||
|
endpoint: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let handshake_str = handshake_at.as_ref().map(format_datetime);
|
||||||
|
|
||||||
|
if handshake_str.is_none() && endpoint.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut query = String::from("UPDATE peers SET ");
|
||||||
|
let mut set_clauses = Vec::new();
|
||||||
|
|
||||||
|
if handshake_str.is_some() {
|
||||||
|
set_clauses.push("last_handshake_at = ?");
|
||||||
|
}
|
||||||
|
if endpoint.is_some() {
|
||||||
|
set_clauses.push("endpoint = ?");
|
||||||
|
}
|
||||||
|
query.push_str(&set_clauses.join(", "));
|
||||||
|
query.push_str(" WHERE id = ?");
|
||||||
|
|
||||||
|
let mut q = sqlx::query(&query);
|
||||||
|
if let Some(ref hs) = handshake_str {
|
||||||
|
q = q.bind(hs);
|
||||||
|
}
|
||||||
|
if let Some(ep) = endpoint {
|
||||||
|
q = q.bind(ep);
|
||||||
|
}
|
||||||
|
q = q.bind(&id_str);
|
||||||
|
|
||||||
|
let result = q.execute(pool).await.map_err(DbError::Sqlx)?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a peer by UUID.
|
/// Delete a peer by UUID.
|
||||||
pub async fn delete_peer(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
pub async fn delete_peer(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
|
|||||||
@@ -349,6 +349,15 @@ impl Store {
|
|||||||
crate::peers::update_peer_handshake(&self.pool, id, handshake_at).await
|
crate::peers::update_peer_handshake(&self.pool, id, handshake_at).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_peer_learned_telemetry(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
handshake_at: Option<chrono::NaiveDateTime>,
|
||||||
|
endpoint: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::peers::update_peer_learned_telemetry(&self.pool, id, handshake_at, endpoint).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_peer(&self, id: uuid::Uuid) -> Result<()> {
|
pub async fn delete_peer(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
crate::peers::delete_peer(&self.pool, id).await
|
crate::peers::delete_peer(&self.pool, id).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ pub mod nftables;
|
|||||||
pub use engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
|
pub use engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
|
||||||
pub use error::{NetworkError, Result};
|
pub use error::{NetworkError, Result};
|
||||||
pub use forwarding::IpForwardingStatus;
|
pub use forwarding::IpForwardingStatus;
|
||||||
pub use nftables::NftablesRulesetBuilder;
|
pub use nftables::{
|
||||||
|
CanonicalNftablesRuleset, NftablesRulesetBuilder, has_nftables_drift, normalize_rule_statement,
|
||||||
|
};
|
||||||
@@ -138,7 +138,7 @@ impl NftablesRulesetBuilder {
|
|||||||
// Build Postrouting / NAT Masquerade rules
|
// Build Postrouting / NAT Masquerade rules
|
||||||
let mut nat_rules = Vec::new();
|
let mut nat_rules = Vec::new();
|
||||||
if enable_nat {
|
if enable_nat {
|
||||||
let mut unique_subnets = wg_subnets.to_vec();
|
let mut unique_subnets: Vec<IpNet> = wg_subnets.iter().map(|s| s.trunc()).collect();
|
||||||
unique_subnets.sort();
|
unique_subnets.sort();
|
||||||
unique_subnets.dedup();
|
unique_subnets.dedup();
|
||||||
|
|
||||||
@@ -200,6 +200,224 @@ impl NftablesRulesetBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Structured semantic representation of the managed `table inet nx9_wg` ruleset.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct CanonicalNftablesRuleset {
|
||||||
|
pub table_exists: bool,
|
||||||
|
pub input_rules: Vec<String>,
|
||||||
|
pub forward_rules: Vec<String>,
|
||||||
|
pub postrouting_rules: Vec<String>,
|
||||||
|
pub other_rules: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanonicalNftablesRuleset {
|
||||||
|
/// Parse an nftables ruleset string (from builder or kernel `list table`) into canonical semantic state.
|
||||||
|
pub fn parse(ruleset: &str) -> Option<Self> {
|
||||||
|
let trimmed_ruleset = ruleset.trim();
|
||||||
|
if trimmed_ruleset.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut in_table = false;
|
||||||
|
let mut current_chain: Option<&str> = None;
|
||||||
|
let mut input_rules = Vec::new();
|
||||||
|
let mut forward_rules = Vec::new();
|
||||||
|
let mut postrouting_rules = Vec::new();
|
||||||
|
let mut other_rules = Vec::new();
|
||||||
|
|
||||||
|
for raw_line in trimmed_ruleset.lines() {
|
||||||
|
// Strip comments (e.g. "# handle 46", "# NX9 WireGuard...")
|
||||||
|
let line_no_comment = if let Some(idx) = raw_line.find('#') {
|
||||||
|
&raw_line[..idx]
|
||||||
|
} else {
|
||||||
|
raw_line
|
||||||
|
};
|
||||||
|
let trimmed = line_no_comment.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.starts_with("table inet nx9_wg") {
|
||||||
|
in_table = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !in_table {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed == "}" {
|
||||||
|
if current_chain.is_some() {
|
||||||
|
current_chain = None;
|
||||||
|
} else {
|
||||||
|
in_table = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.starts_with("chain input") {
|
||||||
|
current_chain = Some("input");
|
||||||
|
continue;
|
||||||
|
} else if trimmed.starts_with("chain forward") {
|
||||||
|
current_chain = Some("forward");
|
||||||
|
continue;
|
||||||
|
} else if trimmed.starts_with("chain postrouting") {
|
||||||
|
current_chain = Some("postrouting");
|
||||||
|
continue;
|
||||||
|
} else if trimmed.starts_with("chain ") {
|
||||||
|
current_chain = Some("other");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip chain type/hook declarations (e.g. "type filter hook input priority ...; policy accept;")
|
||||||
|
if trimmed.starts_with("type filter")
|
||||||
|
|| trimmed.starts_with("type nat")
|
||||||
|
|| trimmed.starts_with("type route")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = normalize_rule_statement(trimmed);
|
||||||
|
if normalized.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match current_chain {
|
||||||
|
Some("input") => input_rules.push(normalized),
|
||||||
|
Some("forward") => forward_rules.push(normalized),
|
||||||
|
Some("postrouting") => postrouting_rules.push(normalized),
|
||||||
|
Some(_) => other_rules.push(normalized),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_table
|
||||||
|
|| !input_rules.is_empty()
|
||||||
|
|| !forward_rules.is_empty()
|
||||||
|
|| !postrouting_rules.is_empty()
|
||||||
|
|| trimmed_ruleset.contains("table inet nx9_wg")
|
||||||
|
{
|
||||||
|
Some(Self {
|
||||||
|
table_exists: true,
|
||||||
|
input_rules,
|
||||||
|
forward_rules,
|
||||||
|
postrouting_rules,
|
||||||
|
other_rules,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize an individual nftables rule statement for canonical comparison.
|
||||||
|
pub fn normalize_rule_statement(rule: &str) -> String {
|
||||||
|
// 1. Strip double quotes (e.g. `"lo"` -> `lo`, `"wg*"` -> `wg*`)
|
||||||
|
let no_quotes = rule.replace('"', "");
|
||||||
|
|
||||||
|
// 2. Normalize whitespace around braces and commas: "{ a, b }" -> "{a,b}"
|
||||||
|
let mut normalized = String::with_capacity(no_quotes.len());
|
||||||
|
let mut chars = no_quotes.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == ',' {
|
||||||
|
normalized.push(',');
|
||||||
|
while let Some(&next) = chars.peek() {
|
||||||
|
if next == ' ' || next == '\t' {
|
||||||
|
chars.next();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if c == '{' {
|
||||||
|
normalized.push('{');
|
||||||
|
while let Some(&next) = chars.peek() {
|
||||||
|
if next == ' ' || next == '\t' {
|
||||||
|
chars.next();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if c == ' ' || c == '\t' {
|
||||||
|
let mut is_before_close_brace = false;
|
||||||
|
let temp_peek = chars.clone();
|
||||||
|
for peek_char in temp_peek {
|
||||||
|
if peek_char == '}' {
|
||||||
|
is_before_close_brace = true;
|
||||||
|
break;
|
||||||
|
} else if peek_char != ' ' && peek_char != '\t' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_before_close_brace {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !normalized.ends_with(' ') && !normalized.is_empty() {
|
||||||
|
normalized.push(' ');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
normalized.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = normalized.trim().to_string();
|
||||||
|
|
||||||
|
// Standardize "ct state {established,related}" to "ct state established,related"
|
||||||
|
if result.contains("ct state {established,related}") {
|
||||||
|
result = result.replace(
|
||||||
|
"ct state {established,related}",
|
||||||
|
"ct state established,related",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standardize redundant leading protocol prefixes before IP selectors
|
||||||
|
if (result.starts_with("tcp ip ") || result.starts_with("tcp ip6 "))
|
||||||
|
&& (result.contains("tcp dport")
|
||||||
|
|| result.contains("tcp sport")
|
||||||
|
|| result.contains("th dport"))
|
||||||
|
{
|
||||||
|
result = result[4..].trim().to_string();
|
||||||
|
}
|
||||||
|
if (result.starts_with("udp ip ") || result.starts_with("udp ip6 "))
|
||||||
|
&& (result.contains("udp dport")
|
||||||
|
|| result.contains("udp sport")
|
||||||
|
|| result.contains("th dport"))
|
||||||
|
{
|
||||||
|
result = result[4..].trim().to_string();
|
||||||
|
}
|
||||||
|
result = result.replace("tcp tcp dport", "tcp dport");
|
||||||
|
result = result.replace("udp udp dport", "udp dport");
|
||||||
|
result = result.replace("tcp tcp sport", "tcp sport");
|
||||||
|
result = result.replace("udp udp sport", "udp sport");
|
||||||
|
|
||||||
|
let mut words: Vec<String> = result.split_whitespace().map(|s| s.to_string()).collect();
|
||||||
|
for word in &mut words {
|
||||||
|
if word.contains('/')
|
||||||
|
&& let Ok(net) = word.parse::<IpNet>()
|
||||||
|
{
|
||||||
|
*word = net.trunc().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = words.join(" ");
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect semantic drift between desired and live nftables rulesets.
|
||||||
|
/// Ignores non-semantic variations such as rule handles (`# handle 46`), formatting, tabs, comments, and hook priority keywords.
|
||||||
|
pub fn has_nftables_drift(expected_ruleset: &str, active_ruleset: &str) -> bool {
|
||||||
|
let expected = CanonicalNftablesRuleset::parse(expected_ruleset);
|
||||||
|
let active = CanonicalNftablesRuleset::parse(active_ruleset);
|
||||||
|
|
||||||
|
match (expected, active) {
|
||||||
|
(Some(exp), Some(act)) => exp != act,
|
||||||
|
(None, None) => false,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -322,7 +540,6 @@ mod tests {
|
|||||||
assert!(ruleset.contains("ip6 saddr fd00:1::/64 oifname != \"wg*\" masquerade"));
|
assert!(ruleset.contains("ip6 saddr fd00:1::/64 oifname != \"wg*\" masquerade"));
|
||||||
assert!(ruleset.contains("ip6 saddr fd00:2::/64 oifname != \"wg*\" masquerade"));
|
assert!(ruleset.contains("ip6 saddr fd00:2::/64 oifname != \"wg*\" masquerade"));
|
||||||
|
|
||||||
// Verify deduplication: 10.100.0.0/24 appears exactly once in masquerade statements
|
|
||||||
let count = ruleset
|
let count = ruleset
|
||||||
.matches("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade")
|
.matches("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade")
|
||||||
.count();
|
.count();
|
||||||
@@ -331,4 +548,189 @@ mod tests {
|
|||||||
"Duplicate subnet must be deduplicated to exactly one masquerade rule"
|
"Duplicate subnet must be deduplicated to exactly one masquerade rule"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_nftables_drift_ignores_handles_and_formatting() {
|
||||||
|
let desired = r#"#!/usr/sbin/nft -f
|
||||||
|
|
||||||
|
# NX9 WireGuard Dedicated Firewall Ruleset
|
||||||
|
table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lo" accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
|
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let live_with_kernel_handles = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy accept;
|
||||||
|
ct state established,related accept # handle 46
|
||||||
|
iifname "lo" accept # handle 1
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority filter; policy accept;
|
||||||
|
ct state established,related accept # handle 4
|
||||||
|
}
|
||||||
|
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
|
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade # handle 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!has_nftables_drift(desired, live_with_kernel_handles),
|
||||||
|
"Desired ruleset and live kernel output with handles and tabs must converge with zero drift"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_nftables_drift_detects_real_rule_changes() {
|
||||||
|
let desired = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lo" accept
|
||||||
|
tcp dport 80 accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
|
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let live_missing_port_80 = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy accept;
|
||||||
|
ct state established,related accept # handle 2
|
||||||
|
iifname "lo" accept # handle 3
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority filter; policy accept;
|
||||||
|
ct state established,related accept # handle 4
|
||||||
|
}
|
||||||
|
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
|
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade # handle 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
has_nftables_drift(desired, live_missing_port_80),
|
||||||
|
"Missing port 80 rule must detect drift"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_nftables_drift_nat_toggle() {
|
||||||
|
let desired_nat_disabled = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lo" accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let live_with_nat = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lo" accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
|
ip saddr 10.100.0.0/24 oifname != "wg*" masquerade # handle 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
has_nftables_drift(desired_nat_disabled, live_with_nat),
|
||||||
|
"Disabling NAT in desired state while active in kernel must detect drift"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_nftables_drift_missing_table() {
|
||||||
|
let desired = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lo" accept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
has_nftables_drift(desired, ""),
|
||||||
|
"Empty active ruleset (missing table) must detect drift"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_nftables_drift_unexpected_rules() {
|
||||||
|
let desired = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lo" accept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let live_with_rogue_rule = r#"table inet nx9_wg {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lo" accept
|
||||||
|
tcp dport 22 drop # handle 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
has_nftables_drift(desired, live_with_rogue_rule),
|
||||||
|
"Unexpected kernel rules must detect drift"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -38,9 +38,10 @@ impl PeerRowView {
|
|||||||
let is_expired =
|
let is_expired =
|
||||||
peer.state == PeerState::Expired || peer.expires_at.is_some_and(|exp| exp <= now);
|
peer.state == PeerState::Expired || peer.expires_at.is_some_and(|exp| exp <= now);
|
||||||
|
|
||||||
|
let maybe_hs = last_handshake.or(peer.last_handshake_at);
|
||||||
let is_online = if is_expired || peer.state != PeerState::Active {
|
let is_online = if is_expired || peer.state != PeerState::Active {
|
||||||
false
|
false
|
||||||
} else if let Some(hs) = last_handshake.or(peer.last_handshake_at) {
|
} else if let Some(hs) = maybe_hs {
|
||||||
let diff = now.signed_duration_since(hs);
|
let diff = now.signed_duration_since(hs);
|
||||||
diff.num_seconds() >= 0 && diff.num_seconds() < 180
|
diff.num_seconds() >= 0 && diff.num_seconds() < 180
|
||||||
} else {
|
} else {
|
||||||
@@ -53,9 +54,14 @@ impl PeerRowView {
|
|||||||
match peer.state {
|
match peer.state {
|
||||||
PeerState::Active => {
|
PeerState::Active => {
|
||||||
if is_online {
|
if is_online {
|
||||||
("Online".to_string(), "status-pass".to_string())
|
("Connected".to_string(), "status-pass".to_string())
|
||||||
|
} else if maybe_hs.is_some() {
|
||||||
|
("Disconnected".to_string(), "status-fail".to_string())
|
||||||
} else {
|
} else {
|
||||||
("Offline".to_string(), "status-neutral".to_string())
|
(
|
||||||
|
"Awaiting Handshake".to_string(),
|
||||||
|
"status-warning".to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PeerState::Disabled => ("Disabled".to_string(), "status-warning".to_string()),
|
PeerState::Disabled => ("Disabled".to_string(), "status-warning".to_string()),
|
||||||
@@ -216,10 +222,18 @@ mod tests {
|
|||||||
|
|
||||||
let row = PeerRowView::from_peer_and_telemetry(&peer, 1024, 2048, None);
|
let row = PeerRowView::from_peer_and_telemetry(&peer, 1024, 2048, None);
|
||||||
assert_eq!(row.name, "alice-laptop");
|
assert_eq!(row.name, "alice-laptop");
|
||||||
assert_eq!(row.status_label, "Offline");
|
assert_eq!(row.status_label, "Awaiting Handshake");
|
||||||
assert_eq!(row.rx_bytes_formatted, "1.0 KB");
|
assert_eq!(row.rx_bytes_formatted, "1.0 KB");
|
||||||
assert_eq!(row.tx_bytes_formatted, "2.0 KB");
|
assert_eq!(row.tx_bytes_formatted, "2.0 KB");
|
||||||
|
|
||||||
|
let recent_hs = Utc::now().naive_utc() - chrono::Duration::seconds(30);
|
||||||
|
let row_online = PeerRowView::from_peer_and_telemetry(&peer, 1024, 2048, Some(recent_hs));
|
||||||
|
assert_eq!(row_online.status_label, "Connected");
|
||||||
|
|
||||||
|
let stale_hs = Utc::now().naive_utc() - chrono::Duration::seconds(300);
|
||||||
|
let row_stale = PeerRowView::from_peer_and_telemetry(&peer, 1024, 2048, Some(stale_hs));
|
||||||
|
assert_eq!(row_stale.status_label, "Disconnected");
|
||||||
|
|
||||||
let filter = PeerTableFilter {
|
let filter = PeerTableFilter {
|
||||||
search_query: "alice".to_string(),
|
search_query: "alice".to_string(),
|
||||||
status_filter: None,
|
status_filter: None,
|
||||||
|
|||||||
@@ -33,6 +33,64 @@ a:hover {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Authentication & Login View ─────────────────────────────────────────────── */
|
||||||
|
#login-view {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header .brand-mark {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
background-color: var(--status-fail-bg);
|
||||||
|
color: var(--status-fail-text);
|
||||||
|
border: 1px solid var(--status-fail-border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── App Shell Layout ────────────────────────────────────────────────────────── */
|
/* ── App Shell Layout ────────────────────────────────────────────────────────── */
|
||||||
#app-layout {
|
#app-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -634,10 +692,30 @@ table.data-table tr:hover td {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 20px;
|
padding: 24px;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-target-box {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-target-box svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 240px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-image {
|
.qr-image {
|
||||||
|
|||||||
@@ -79,18 +79,31 @@ impl ClientConfigBuilder {
|
|||||||
lines.push(format!("PresharedKey = {}", psk.as_str()));
|
lines.push(format!("PresharedKey = {}", psk.as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let host_trimmed = server_host_or_ip.trim();
|
||||||
|
if host_trimmed.is_empty() {
|
||||||
|
return Err(WireGuardError::Config(
|
||||||
|
"No reachable WireGuard server endpoint is configured. Configure 'server_endpoint' in settings or provide --endpoint.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Endpoint
|
// Endpoint
|
||||||
let endpoint = if server_host_or_ip.contains(':') && !server_host_or_ip.starts_with('[') {
|
let endpoint = if host_trimmed.contains(':') && !host_trimmed.starts_with('[') {
|
||||||
// Check if already contains port
|
// Check if already contains port
|
||||||
server_host_or_ip.to_string()
|
host_trimmed.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{}:{}", server_host_or_ip, interface.listen_port)
|
format!("{}:{}", host_trimmed, interface.listen_port)
|
||||||
};
|
};
|
||||||
lines.push(format!("Endpoint = {endpoint}"));
|
lines.push(format!("Endpoint = {endpoint}"));
|
||||||
|
|
||||||
// AllowedIPs based on Peer Profile
|
// AllowedIPs based on Peer Profile
|
||||||
let allowed_ips = match peer.profile {
|
let allowed_ips = match peer.profile {
|
||||||
PeerProfile::FullTunnel => "0.0.0.0/0, ::/0".to_string(),
|
PeerProfile::FullTunnel => {
|
||||||
|
if interface.address_v6.is_some() || peer.address_v6.is_some() {
|
||||||
|
"0.0.0.0/0, ::/0".to_string()
|
||||||
|
} else {
|
||||||
|
"0.0.0.0/0".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
PeerProfile::SplitTunnel => {
|
PeerProfile::SplitTunnel => {
|
||||||
let mut subnets = Vec::new();
|
let mut subnets = Vec::new();
|
||||||
subnets.push(interface.address_v4.to_string());
|
subnets.push(interface.address_v4.to_string());
|
||||||
@@ -101,7 +114,11 @@ impl ClientConfigBuilder {
|
|||||||
}
|
}
|
||||||
PeerProfile::Custom => {
|
PeerProfile::Custom => {
|
||||||
if peer.allowed_ips.trim().is_empty() {
|
if peer.allowed_ips.trim().is_empty() {
|
||||||
|
if interface.address_v6.is_some() || peer.address_v6.is_some() {
|
||||||
"0.0.0.0/0, ::/0".to_string()
|
"0.0.0.0/0, ::/0".to_string()
|
||||||
|
} else {
|
||||||
|
"0.0.0.0/0".to_string()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
peer.allowed_ips.clone()
|
peer.allowed_ips.clone()
|
||||||
}
|
}
|
||||||
@@ -181,7 +198,7 @@ mod tests {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Full Tunnel
|
// Full Tunnel (IPv4-only interface -> 0.0.0.0/0 to prevent silent IPv6 blackhole)
|
||||||
let full_conf = ClientConfigBuilder::build(&peer, &iface, "vpn.example.com").unwrap();
|
let full_conf = ClientConfigBuilder::build(&peer, &iface, "vpn.example.com").unwrap();
|
||||||
assert!(full_conf.contains(&format!("PrivateKey = {}", peer_priv.as_str())));
|
assert!(full_conf.contains(&format!("PrivateKey = {}", peer_priv.as_str())));
|
||||||
assert!(full_conf.contains("Address = 10.0.0.2/32"));
|
assert!(full_conf.contains("Address = 10.0.0.2/32"));
|
||||||
@@ -190,9 +207,15 @@ mod tests {
|
|||||||
assert!(full_conf.contains(&format!("PublicKey = {}", srv_pub.as_str())));
|
assert!(full_conf.contains(&format!("PublicKey = {}", srv_pub.as_str())));
|
||||||
assert!(full_conf.contains(&format!("PresharedKey = {}", psk.as_str())));
|
assert!(full_conf.contains(&format!("PresharedKey = {}", psk.as_str())));
|
||||||
assert!(full_conf.contains("Endpoint = vpn.example.com:51820"));
|
assert!(full_conf.contains("Endpoint = vpn.example.com:51820"));
|
||||||
assert!(full_conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
|
assert!(full_conf.contains("AllowedIPs = 0.0.0.0/0"));
|
||||||
assert!(full_conf.contains("PersistentKeepalive = 25"));
|
assert!(full_conf.contains("PersistentKeepalive = 25"));
|
||||||
|
|
||||||
|
// Full Tunnel (Dual-stack interface -> 0.0.0.0/0, ::/0)
|
||||||
|
let mut dual_iface = iface.clone();
|
||||||
|
dual_iface.address_v6 = Some(IpNet::from_str("fd00::1/64").unwrap());
|
||||||
|
let dual_conf = ClientConfigBuilder::build(&peer, &dual_iface, "vpn.example.com").unwrap();
|
||||||
|
assert!(dual_conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
|
||||||
|
|
||||||
// Split Tunnel
|
// Split Tunnel
|
||||||
peer.profile = PeerProfile::SplitTunnel;
|
peer.profile = PeerProfile::SplitTunnel;
|
||||||
let split_conf = ClientConfigBuilder::build(&peer, &iface, "vpn.example.com").unwrap();
|
let split_conf = ClientConfigBuilder::build(&peer, &iface, "vpn.example.com").unwrap();
|
||||||
@@ -274,6 +297,6 @@ mod tests {
|
|||||||
assert!(conf.contains("PersistentKeepalive = 20"));
|
assert!(conf.contains("PersistentKeepalive = 20"));
|
||||||
assert!(conf.contains("DNS = 9.9.9.9"));
|
assert!(conf.contains("DNS = 9.9.9.9"));
|
||||||
assert!(conf.contains("Address = 10.0.0.5/32"));
|
assert!(conf.contains("Address = 10.0.0.5/32"));
|
||||||
assert!(conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
|
assert!(conf.contains("AllowedIPs = 0.0.0.0/0"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,12 @@ pub struct LiveInterfaceStats {
|
|||||||
pub listen_port: u16,
|
pub listen_port: u16,
|
||||||
pub fwmark: u32,
|
pub fwmark: u32,
|
||||||
pub peers: Vec<LivePeerStats>,
|
pub peers: Vec<LivePeerStats>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub addresses: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mtu: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_up: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abstract WireGuard Engine interface for kernel netlink and simulated environments.
|
/// Abstract WireGuard Engine interface for kernel netlink and simulated environments.
|
||||||
@@ -82,6 +88,12 @@ impl SimulatedWireGuardEngine {
|
|||||||
"Peer '{peer_public_key}' on interface '{interface_name}' not found"
|
"Peer '{peer_public_key}' on interface '{interface_name}' not found"
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directly inject live interface stats (for testing drift and telemetry scenarios).
|
||||||
|
pub async fn inject_interface_stats(&self, stats: LiveInterfaceStats) {
|
||||||
|
let mut map = self.state.write().await;
|
||||||
|
map.insert(stats.name.clone(), stats);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -94,7 +106,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
|
|||||||
.filter(|p| p.state == PeerState::Active)
|
.filter(|p| p.state == PeerState::Active)
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let allowed_ips: Vec<String> = p
|
let allowed_ips: Vec<String> = p
|
||||||
.allowed_ips
|
.server_wireguard_allowed_ips()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -112,12 +124,20 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let mut addresses = vec![interface.address_v4.to_string()];
|
||||||
|
if let Some(ref v6) = interface.address_v6 {
|
||||||
|
addresses.push(v6.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let stats = LiveInterfaceStats {
|
let stats = LiveInterfaceStats {
|
||||||
name: interface.name.clone(),
|
name: interface.name.clone(),
|
||||||
public_key: interface.public_key.as_str().to_string(),
|
public_key: interface.public_key.as_str().to_string(),
|
||||||
listen_port: interface.listen_port,
|
listen_port: interface.listen_port,
|
||||||
fwmark: 0,
|
fwmark: 0,
|
||||||
peers: live_peers,
|
peers: live_peers,
|
||||||
|
addresses,
|
||||||
|
mtu: interface.mtu.map(|m| m as u32),
|
||||||
|
is_up: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
map.insert(interface.name.clone(), stats);
|
map.insert(interface.name.clone(), stats);
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ use netlink_packet_wireguard::{
|
|||||||
};
|
};
|
||||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
||||||
use rtnetlink::LinkWireguard;
|
use rtnetlink::LinkWireguard;
|
||||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkInfo};
|
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
||||||
|
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo};
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
|
||||||
/// Linux Native WireGuard Engine using kernel RTNETLINK and Generic Netlink.
|
/// Linux Native WireGuard Engine using kernel RTNETLINK and Generic Netlink.
|
||||||
@@ -55,42 +56,53 @@ async fn rtnetlink_handle() -> Result<(rtnetlink::Handle, tokio::task::JoinHandl
|
|||||||
Ok((handle, join))
|
Ok((handle, join))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure a WireGuard interface exists with the given name.
|
/// Ensure a WireGuard interface exists with the given name and addresses.
|
||||||
///
|
///
|
||||||
/// - If the interface already exists and is a WireGuard link, this is a no-op.
|
|
||||||
/// - If the interface already exists but is NOT a WireGuard link, returns an error.
|
|
||||||
/// - If the interface does not exist, it is created as a WireGuard link and brought up.
|
/// - If the interface does not exist, it is created as a WireGuard link and brought up.
|
||||||
async fn ensure_link(name: &str) -> Result<()> {
|
/// - Sets MTU if configured.
|
||||||
|
/// - Assigns IPv4 and IPv6 addresses via RTNETLINK if not already assigned.
|
||||||
|
/// - Removes stale IP addresses on the managed interface that do not match desired state.
|
||||||
|
async fn ensure_link_and_addresses(interface: &Interface) -> Result<()> {
|
||||||
let (handle, _conn_task) = rtnetlink_handle().await?;
|
let (handle, _conn_task) = rtnetlink_handle().await?;
|
||||||
|
|
||||||
// Try to find existing interface by name
|
// Try to find existing interface by name
|
||||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
let mut links = handle
|
||||||
|
.link()
|
||||||
|
.get()
|
||||||
|
.match_name(interface.name.to_string())
|
||||||
|
.execute();
|
||||||
|
|
||||||
match links.try_next().await {
|
let link_index = match links.try_next().await {
|
||||||
Ok(Some(link)) => {
|
Ok(Some(link)) => {
|
||||||
let mut is_wireguard = false;
|
let mut is_other_type = false;
|
||||||
for nla in &link.attributes {
|
for nla in &link.attributes {
|
||||||
if let LinkAttribute::LinkInfo(infos) = nla {
|
if let LinkAttribute::LinkInfo(infos) = nla {
|
||||||
for info in infos {
|
for info in infos {
|
||||||
if let LinkInfo::Kind(InfoKind::Wireguard) = info {
|
match info {
|
||||||
is_wireguard = true;
|
LinkInfo::Kind(InfoKind::Wireguard) => {}
|
||||||
|
LinkInfo::Kind(InfoKind::Other(k))
|
||||||
|
if k.eq_ignore_ascii_case("wireguard") => {}
|
||||||
|
LinkInfo::Kind(_) => {
|
||||||
|
is_other_type = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if is_wireguard {
|
if is_other_type {
|
||||||
tracing::debug!(interface = %name, "WireGuard interface already exists");
|
return Err(WireGuardError::WrongInterfaceType(format!(
|
||||||
Ok(())
|
"interface '{}' exists but is not a WireGuard interface",
|
||||||
} else {
|
interface.name
|
||||||
Err(WireGuardError::WrongInterfaceType(format!(
|
)));
|
||||||
"interface '{name}' exists but is not a WireGuard interface"
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
|
tracing::debug!(interface = %interface.name, index = link.header.index, "WireGuard interface found");
|
||||||
|
link.header.index
|
||||||
}
|
}
|
||||||
Ok(None) | Err(_) => {
|
Ok(None) | Err(_) => {
|
||||||
// Interface does not exist — create it and bring it up
|
// Interface does not exist — create it and bring it up
|
||||||
tracing::info!(interface = %name, "Creating WireGuard interface via RTNETLINK");
|
tracing::info!(interface = %interface.name, "Creating WireGuard interface via RTNETLINK");
|
||||||
let add_msg = LinkWireguard::new(name).up().build();
|
let add_msg = LinkWireguard::new(&interface.name).up().build();
|
||||||
|
|
||||||
handle.link().add(add_msg).execute().await.map_err(|e| {
|
handle.link().add(add_msg).execute().await.map_err(|e| {
|
||||||
let msg = format!("{e}");
|
let msg = format!("{e}");
|
||||||
@@ -99,19 +111,208 @@ async fn ensure_link(name: &str) -> Result<()> {
|
|||||||
|| msg.contains("Operation not permitted")
|
|| msg.contains("Operation not permitted")
|
||||||
{
|
{
|
||||||
WireGuardError::PermissionDenied(format!(
|
WireGuardError::PermissionDenied(format!(
|
||||||
"insufficient privileges to create WireGuard interface '{name}': {e}"
|
"insufficient privileges to create WireGuard interface '{}': {e}",
|
||||||
|
interface.name
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
WireGuardError::Netlink(format!(
|
WireGuardError::Netlink(format!(
|
||||||
"failed to create WireGuard interface '{name}': {e}"
|
"failed to create WireGuard interface '{}': {e}",
|
||||||
|
interface.name
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!(interface = %name, "WireGuard interface created and brought up");
|
// Retrieve newly created link to get its index
|
||||||
|
let mut new_links = handle
|
||||||
|
.link()
|
||||||
|
.get()
|
||||||
|
.match_name(interface.name.to_string())
|
||||||
|
.execute();
|
||||||
|
match new_links.try_next().await {
|
||||||
|
Ok(Some(nl)) => {
|
||||||
|
tracing::info!(interface = %interface.name, "WireGuard interface created and brought up");
|
||||||
|
nl.header.index
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(WireGuardError::Netlink(format!(
|
||||||
|
"failed to retrieve newly created interface '{}'",
|
||||||
|
interface.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set MTU and ensure UP
|
||||||
|
let mut link_builder = rtnetlink::LinkUnspec::new_with_index(link_index).up();
|
||||||
|
if let Some(mtu) = interface.mtu {
|
||||||
|
link_builder = link_builder.mtu(mtu as u32);
|
||||||
|
}
|
||||||
|
let msg = link_builder.build();
|
||||||
|
if let Err(e) = handle.link().change(msg).execute().await {
|
||||||
|
let msg_str = format!("{e}");
|
||||||
|
if msg_str.contains("permission")
|
||||||
|
|| msg_str.contains("EPERM")
|
||||||
|
|| msg_str.contains("Operation not permitted")
|
||||||
|
{
|
||||||
|
return Err(WireGuardError::PermissionDenied(format!(
|
||||||
|
"insufficient privileges to set link UP/MTU for '{}': {e}",
|
||||||
|
interface.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tracing::warn!(interface = %interface.name, "Failed to set link UP/MTU: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing addresses on link
|
||||||
|
let mut existing_addrs: Vec<(IpAddr, u8)> = Vec::new();
|
||||||
|
let mut stale_addr_msgs: Vec<AddressMessage> = Vec::new();
|
||||||
|
|
||||||
|
let mut addr_stream = handle
|
||||||
|
.address()
|
||||||
|
.get()
|
||||||
|
.set_link_index_filter(link_index)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
let desired_v4_ip = interface.address_v4.addr();
|
||||||
|
let desired_v4_prefix = interface.address_v4.prefix_len();
|
||||||
|
let desired_v6 = interface.address_v6.as_ref();
|
||||||
|
|
||||||
|
while let Ok(Some(addr_msg)) = addr_stream.try_next().await {
|
||||||
|
let prefix = addr_msg.header.prefix_len;
|
||||||
|
let mut msg_ip: Option<IpAddr> = None;
|
||||||
|
|
||||||
|
for attr in &addr_msg.attributes {
|
||||||
|
match attr {
|
||||||
|
AddressAttribute::Address(ip) | AddressAttribute::Local(ip) => {
|
||||||
|
if !existing_addrs.contains(&(*ip, prefix)) {
|
||||||
|
existing_addrs.push((*ip, prefix));
|
||||||
|
}
|
||||||
|
msg_ip = Some(*ip);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ip) = msg_ip {
|
||||||
|
let is_desired = match ip {
|
||||||
|
IpAddr::V4(v4) => v4 == desired_v4_ip && prefix == desired_v4_prefix,
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
if v6.is_unicast_link_local() {
|
||||||
|
true // preserve IPv6 link-local fe80::/10
|
||||||
|
} else if let Some(v6_desired) = desired_v6 {
|
||||||
|
v6 == v6_desired.addr() && prefix == v6_desired.prefix_len()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_desired {
|
||||||
|
stale_addr_msgs.push(addr_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any stale addresses
|
||||||
|
for stale_msg in stale_addr_msgs {
|
||||||
|
if let Err(e) = handle.address().del(stale_msg).execute().await {
|
||||||
|
tracing::warn!(interface = %interface.name, "Failed to delete stale address: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add desired IPv4 address if not already present
|
||||||
|
if !existing_addrs
|
||||||
|
.iter()
|
||||||
|
.any(|(ip, p)| *ip == desired_v4_ip && *p == desired_v4_prefix)
|
||||||
|
{
|
||||||
|
handle
|
||||||
|
.address()
|
||||||
|
.add(link_index, desired_v4_ip, desired_v4_prefix)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = format!("{e}");
|
||||||
|
if msg.contains("permission")
|
||||||
|
|| msg.contains("EPERM")
|
||||||
|
|| msg.contains("Operation not permitted")
|
||||||
|
{
|
||||||
|
WireGuardError::PermissionDenied(format!(
|
||||||
|
"insufficient privileges to assign IPv4 address '{}/{}' to interface '{}': {e}",
|
||||||
|
desired_v4_ip, desired_v4_prefix, interface.name
|
||||||
|
))
|
||||||
|
} else if msg.contains("File exists") || msg.contains("EEXIST") {
|
||||||
|
WireGuardError::Netlink(e.to_string())
|
||||||
|
} else {
|
||||||
|
WireGuardError::Netlink(format!(
|
||||||
|
"failed to assign IPv4 address '{}/{}' to interface '{}': {e}",
|
||||||
|
desired_v4_ip, desired_v4_prefix, interface.name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|e| {
|
||||||
|
if e.to_string().contains("File exists") || e.to_string().contains("EEXIST") {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
tracing::info!(
|
||||||
|
interface = %interface.name,
|
||||||
|
ip = %desired_v4_ip,
|
||||||
|
prefix = desired_v4_prefix,
|
||||||
|
"Assigned IPv4 address to WireGuard interface via RTNETLINK"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add desired IPv6 address if present and not already configured
|
||||||
|
if let Some(v6) = desired_v6 {
|
||||||
|
let v6_ip = v6.addr();
|
||||||
|
let v6_prefix = v6.prefix_len();
|
||||||
|
if !existing_addrs
|
||||||
|
.iter()
|
||||||
|
.any(|(ip, p)| *ip == v6_ip && *p == v6_prefix)
|
||||||
|
{
|
||||||
|
handle
|
||||||
|
.address()
|
||||||
|
.add(link_index, v6_ip, v6_prefix)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = format!("{e}");
|
||||||
|
if msg.contains("permission")
|
||||||
|
|| msg.contains("EPERM")
|
||||||
|
|| msg.contains("Operation not permitted")
|
||||||
|
{
|
||||||
|
WireGuardError::PermissionDenied(format!(
|
||||||
|
"insufficient privileges to assign IPv6 address '{}/{}' to interface '{}': {e}",
|
||||||
|
v6_ip, v6_prefix, interface.name
|
||||||
|
))
|
||||||
|
} else if msg.contains("File exists") || msg.contains("EEXIST") {
|
||||||
|
WireGuardError::Netlink(e.to_string())
|
||||||
|
} else {
|
||||||
|
WireGuardError::Netlink(format!(
|
||||||
|
"failed to assign IPv6 address '{}/{}' to interface '{}': {e}",
|
||||||
|
v6_ip, v6_prefix, interface.name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|e| {
|
||||||
|
if e.to_string().contains("File exists") || e.to_string().contains("EEXIST") {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
tracing::info!(
|
||||||
|
interface = %interface.name,
|
||||||
|
ip = %v6_ip,
|
||||||
|
prefix = v6_prefix,
|
||||||
|
"Assigned IPv6 address to WireGuard interface via RTNETLINK"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a WireGuard interface by name.
|
/// Delete a WireGuard interface by name.
|
||||||
@@ -140,17 +341,18 @@ async fn delete_link(name: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all WireGuard interface names using RTNETLINK link dump.
|
/// List all WireGuard interface names using RTNETLINK link dump and Generic Netlink enumeration.
|
||||||
async fn list_wireguard_links() -> Result<Vec<String>> {
|
async fn list_wireguard_links() -> Result<Vec<String>> {
|
||||||
let (handle, _conn_task) = rtnetlink_handle().await?;
|
|
||||||
|
|
||||||
let mut links = handle.link().get().execute();
|
|
||||||
let mut wg_names = Vec::new();
|
let mut wg_names = Vec::new();
|
||||||
|
|
||||||
|
// 1. Primary discovery: RTNETLINK link dump
|
||||||
|
let (handle, _conn_task) = rtnetlink_handle().await?;
|
||||||
|
let mut links = handle.link().get().execute();
|
||||||
|
|
||||||
while let Some(link) = links
|
while let Some(link) = links
|
||||||
.try_next()
|
.try_next()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WireGuardError::Netlink(format!("failed to dump links: {e}")))?
|
.map_err(|e| WireGuardError::Netlink(format!("failed to dump links via rtnetlink: {e}")))?
|
||||||
{
|
{
|
||||||
let mut name = None;
|
let mut name = None;
|
||||||
let mut is_wireguard = false;
|
let mut is_wireguard = false;
|
||||||
@@ -160,20 +362,59 @@ async fn list_wireguard_links() -> Result<Vec<String>> {
|
|||||||
LinkAttribute::IfName(n) => name = Some(n.clone()),
|
LinkAttribute::IfName(n) => name = Some(n.clone()),
|
||||||
LinkAttribute::LinkInfo(infos) => {
|
LinkAttribute::LinkInfo(infos) => {
|
||||||
for info in infos {
|
for info in infos {
|
||||||
if let LinkInfo::Kind(InfoKind::Wireguard) = info {
|
match info {
|
||||||
|
LinkInfo::Kind(InfoKind::Wireguard) => is_wireguard = true,
|
||||||
|
LinkInfo::Kind(InfoKind::Other(k))
|
||||||
|
if k.eq_ignore_ascii_case("wireguard") =>
|
||||||
|
{
|
||||||
is_wireguard = true;
|
is_wireguard = true;
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let (true, Some(n)) = (is_wireguard, name) {
|
if let (true, Some(n)) = (is_wireguard, name)
|
||||||
|
&& !wg_names.contains(&n)
|
||||||
|
{
|
||||||
wg_names.push(n);
|
wg_names.push(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Secondary discovery: WireGuard Generic Netlink dump
|
||||||
|
if let Ok((mut genl_handle, _)) = wireguard_genl_handle().await {
|
||||||
|
let genlmsg = GenlMessage::from_payload(WireguardMessage {
|
||||||
|
cmd: WireguardCmd::GetDevice,
|
||||||
|
attributes: Vec::new(),
|
||||||
|
});
|
||||||
|
let mut nlmsg = NetlinkMessage::from(genlmsg);
|
||||||
|
nlmsg.header.flags = NLM_F_REQUEST | NLM_F_DUMP;
|
||||||
|
nlmsg.finalize();
|
||||||
|
|
||||||
|
if let Ok(mut response) = genl_handle.request(nlmsg).await {
|
||||||
|
while let Some(Ok(msg)) = response.next().await {
|
||||||
|
if let NetlinkPayload::InnerMessage(genl) = msg.payload {
|
||||||
|
for attr in genl.payload.attributes {
|
||||||
|
if let WireguardAttribute::IfName(ifname) = attr
|
||||||
|
&& !wg_names.contains(&ifname)
|
||||||
|
{
|
||||||
|
wg_names.push(ifname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
discovered_count = wg_names.len(),
|
||||||
|
interfaces = ?wg_names,
|
||||||
|
"Discovered WireGuard interfaces in kernel"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(wg_names)
|
Ok(wg_names)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +477,9 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
|||||||
peer_attrs.push(WireguardPeerAttribute::PersistentKeepalive(keepalive));
|
peer_attrs.push(WireguardPeerAttribute::PersistentKeepalive(keepalive));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowed IPs
|
// Server-side Allowed IPs (cryptokey routing in Linux kernel)
|
||||||
let allowed_ips = parse_allowed_ips(&peer.allowed_ips)?;
|
let server_allowed_str = peer.server_wireguard_allowed_ips();
|
||||||
|
let allowed_ips = parse_allowed_ips(&server_allowed_str)?;
|
||||||
if !allowed_ips.is_empty() {
|
if !allowed_ips.is_empty() {
|
||||||
peer_attrs.push(WireguardPeerAttribute::Flags(
|
peer_attrs.push(WireguardPeerAttribute::Flags(
|
||||||
WireguardPeerFlags::ReplaceAllowedIps,
|
WireguardPeerFlags::ReplaceAllowedIps,
|
||||||
@@ -299,6 +541,51 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
|||||||
|
|
||||||
/// Query a WireGuard device via Generic Netlink GET_DEVICE and return live stats.
|
/// Query a WireGuard device via Generic Netlink GET_DEVICE and return live stats.
|
||||||
async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
|
async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
|
||||||
|
// 1. Query RTNETLINK for link existence, MTU, is_up, and assigned addresses
|
||||||
|
let mut link_exists = false;
|
||||||
|
let mut addresses = Vec::new();
|
||||||
|
let mut mtu = None;
|
||||||
|
let mut is_up = false;
|
||||||
|
|
||||||
|
if let Ok((rt_handle, _)) = rtnetlink_handle().await {
|
||||||
|
let mut links = rt_handle
|
||||||
|
.link()
|
||||||
|
.get()
|
||||||
|
.match_name(name.to_string())
|
||||||
|
.execute();
|
||||||
|
if let Ok(Some(link)) = links.try_next().await {
|
||||||
|
link_exists = true;
|
||||||
|
let index = link.header.index;
|
||||||
|
is_up = link.header.flags.contains(LinkFlags::Up);
|
||||||
|
for attr in link.attributes {
|
||||||
|
if let LinkAttribute::Mtu(m) = attr {
|
||||||
|
mtu = Some(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut addr_stream = rt_handle
|
||||||
|
.address()
|
||||||
|
.get()
|
||||||
|
.set_link_index_filter(index)
|
||||||
|
.execute();
|
||||||
|
while let Ok(Some(addr_msg)) = addr_stream.try_next().await {
|
||||||
|
let prefix = addr_msg.header.prefix_len;
|
||||||
|
for attr in addr_msg.attributes {
|
||||||
|
match attr {
|
||||||
|
AddressAttribute::Address(ip) | AddressAttribute::Local(ip) => {
|
||||||
|
let cidr = format!("{}/{}", ip, prefix);
|
||||||
|
if !addresses.contains(&cidr) {
|
||||||
|
addresses.push(cidr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Query Generic Netlink for WireGuard keys, port, fwmark, and peers
|
||||||
let (mut handle, _conn_task) = wireguard_genl_handle().await?;
|
let (mut handle, _conn_task) = wireguard_genl_handle().await?;
|
||||||
|
|
||||||
let genlmsg = GenlMessage::from_payload(WireguardMessage {
|
let genlmsg = GenlMessage::from_payload(WireguardMessage {
|
||||||
@@ -310,18 +597,35 @@ async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
|
|||||||
nlmsg.header.flags = NLM_F_REQUEST | NLM_F_DUMP;
|
nlmsg.header.flags = NLM_F_REQUEST | NLM_F_DUMP;
|
||||||
nlmsg.finalize();
|
nlmsg.finalize();
|
||||||
|
|
||||||
let mut response = handle.request(nlmsg).await.map_err(|e| {
|
let mut response = match handle.request(nlmsg).await {
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
let msg = format!("{e}");
|
let msg = format!("{e}");
|
||||||
if msg.contains("No such device") || msg.contains("ENODEV") {
|
if msg.contains("No such device") || msg.contains("ENODEV") {
|
||||||
WireGuardError::InterfaceNotFound(format!("interface '{name}' not found"))
|
if link_exists {
|
||||||
} else if msg.contains("not found") || msg.contains("No such") {
|
return Ok(Some(LiveInterfaceStats {
|
||||||
WireGuardError::Unsupported(
|
name: name.to_string(),
|
||||||
"WireGuard Generic Netlink family not available — is the wireguard kernel module loaded?".to_string(),
|
public_key: String::new(),
|
||||||
)
|
listen_port: 0,
|
||||||
} else {
|
fwmark: 0,
|
||||||
WireGuardError::Netlink(format!("failed to query WireGuard device '{name}': {e}"))
|
peers: Vec::new(),
|
||||||
|
addresses,
|
||||||
|
mtu,
|
||||||
|
is_up,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
})?;
|
return Ok(None);
|
||||||
|
} else if msg.contains("not found") || msg.contains("No such") {
|
||||||
|
return Err(WireGuardError::Unsupported(
|
||||||
|
"WireGuard Generic Netlink family not available — is the wireguard kernel module loaded?".to_string(),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return Err(WireGuardError::Netlink(format!(
|
||||||
|
"failed to query WireGuard device '{name}': {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut public_key = String::new();
|
let mut public_key = String::new();
|
||||||
let mut listen_port: u16 = 0;
|
let mut listen_port: u16 = 0;
|
||||||
@@ -335,11 +639,40 @@ async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
|
|||||||
NetlinkPayload::Error(err) => {
|
NetlinkPayload::Error(err) => {
|
||||||
if let Some(code) = err.code {
|
if let Some(code) = err.code {
|
||||||
let code_val = code.get();
|
let code_val = code.get();
|
||||||
// ENODEV = -19 means device not found
|
|
||||||
if code_val == -19 {
|
if code_val == -19 {
|
||||||
|
// ENODEV
|
||||||
|
if link_exists {
|
||||||
|
return Ok(Some(LiveInterfaceStats {
|
||||||
|
name: name.to_string(),
|
||||||
|
public_key: String::new(),
|
||||||
|
listen_port: 0,
|
||||||
|
fwmark: 0,
|
||||||
|
peers: Vec::new(),
|
||||||
|
addresses,
|
||||||
|
mtu,
|
||||||
|
is_up,
|
||||||
|
}));
|
||||||
|
}
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if code_val == -1 {
|
if code_val == -1 {
|
||||||
|
// EPERM
|
||||||
|
if link_exists {
|
||||||
|
tracing::warn!(
|
||||||
|
interface = %name,
|
||||||
|
"Permission denied reading WireGuard keys via Generic Netlink; returning RTNETLINK link info"
|
||||||
|
);
|
||||||
|
return Ok(Some(LiveInterfaceStats {
|
||||||
|
name: name.to_string(),
|
||||||
|
public_key: String::new(),
|
||||||
|
listen_port: 0,
|
||||||
|
fwmark: 0,
|
||||||
|
peers: Vec::new(),
|
||||||
|
addresses,
|
||||||
|
mtu,
|
||||||
|
is_up,
|
||||||
|
}));
|
||||||
|
}
|
||||||
return Err(WireGuardError::PermissionDenied(
|
return Err(WireGuardError::PermissionDenied(
|
||||||
"insufficient privileges to query WireGuard device".to_string(),
|
"insufficient privileges to query WireGuard device".to_string(),
|
||||||
));
|
));
|
||||||
@@ -438,16 +771,28 @@ async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found && !link_exists {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
interface = %name,
|
||||||
|
listen_port,
|
||||||
|
peer_count = live_peers.len(),
|
||||||
|
addresses_count = addresses.len(),
|
||||||
|
is_up,
|
||||||
|
"Retrieved live WireGuard interface telemetry"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Some(LiveInterfaceStats {
|
Ok(Some(LiveInterfaceStats {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
public_key,
|
public_key,
|
||||||
listen_port,
|
listen_port,
|
||||||
fwmark,
|
fwmark,
|
||||||
peers: live_peers,
|
peers: live_peers,
|
||||||
|
addresses,
|
||||||
|
mtu,
|
||||||
|
is_up,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,8 +857,8 @@ fn parse_endpoint(s: &str) -> Result<SocketAddr> {
|
|||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl WireGuardEngine for NativeLinuxWireGuardEngine {
|
impl WireGuardEngine for NativeLinuxWireGuardEngine {
|
||||||
async fn sync_interface(&self, interface: &Interface, peers: &[Peer]) -> Result<()> {
|
async fn sync_interface(&self, interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||||
// 1. Ensure the WireGuard link exists
|
// 1. Ensure the WireGuard link exists and addresses/MTU are configured
|
||||||
ensure_link(&interface.name).await?;
|
ensure_link_and_addresses(interface).await?;
|
||||||
|
|
||||||
// 2. Configure the WireGuard device (private key, listen port, peers)
|
// 2. Configure the WireGuard device (private key, listen port, peers)
|
||||||
configure_device(interface, peers).await?;
|
configure_device(interface, peers).await?;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_native_linux_live_discovery() {
|
||||||
|
use nx9_wireguard::NativeLinuxWireGuardEngine;
|
||||||
|
use nx9_wireguard::WireGuardEngine;
|
||||||
|
|
||||||
|
let engine = NativeLinuxWireGuardEngine::new();
|
||||||
|
let ifaces = engine.list_interfaces().await.unwrap();
|
||||||
|
println!("Discovered WireGuard interfaces: {:?}", ifaces);
|
||||||
|
|
||||||
|
// Ensure list_interfaces returns a valid list
|
||||||
|
for iface in &ifaces {
|
||||||
|
match engine.get_interface_stats(iface).await {
|
||||||
|
Ok(Some(stats)) => {
|
||||||
|
println!(
|
||||||
|
"Interface {iface} live stats: public_key={:?}, port={}, peers={}, up={}",
|
||||||
|
stats.public_key,
|
||||||
|
stats.listen_port,
|
||||||
|
stats.peers.len(),
|
||||||
|
stats.is_up
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(None) => println!("Interface {iface} returned None"),
|
||||||
|
Err(e) => println!("Interface {iface} query returned error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,9 @@ async fn test_wireguard_engine_lifecycle_and_telemetry() {
|
|||||||
.expect("interface exists");
|
.expect("interface exists");
|
||||||
assert_eq!(stats.name, "wg0");
|
assert_eq!(stats.name, "wg0");
|
||||||
assert_eq!(stats.public_key, srv_pub.as_str());
|
assert_eq!(stats.public_key, srv_pub.as_str());
|
||||||
|
assert_eq!(stats.addresses, vec!["10.0.0.1/24".to_string()]);
|
||||||
|
assert_eq!(stats.mtu, Some(1420));
|
||||||
|
assert!(stats.is_up);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stats.peers.len(),
|
stats.peers.len(),
|
||||||
1,
|
1,
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ Welcome to the official documentation for the **NX9 WireGuard (`nx9-wg`)** appli
|
|||||||
|
|
||||||
## 5. Operations, Development & Release
|
## 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.
|
||||||
- [**Quality Assurance & Testing Strategy**](testing.md) — Multi-tiered test suites, SAFE mode (`LIVE=0`) vs real-kernel mode (`LIVE=1`), and automated security audits.
|
- [**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.
|
- [**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.
|
- [**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.
|
- [**Docker & Container Deployment**](docker.md) — Containerized deployment with Linux capability isolation and volume persistence.
|
||||||
+487
@@ -0,0 +1,487 @@
|
|||||||
|
# NX9-WG v1.0.0 — Comprehensive Testing Specification
|
||||||
|
|
||||||
|
This document is the authoritative testing and release-acceptance specification for NX9-WG.
|
||||||
|
|
||||||
|
NX9-WG is a native Linux WireGuard, networking, firewall/NAT, reconciliation, telemetry, and WebUI control plane. Testing therefore covers both the Rust control plane and the Linux kernel data plane.
|
||||||
|
|
||||||
|
> **Release principle:** Passing unit and integration tests does not substitute for physical WireGuard client validation. A production VPN release must distinguish simulated/control-plane evidence from real packet-path evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Testing Philosophy
|
||||||
|
|
||||||
|
Testing is layered from deterministic Rust unit tests through native Linux kernel integration and real client acceptance. Each layer has a defined scope and must not be represented as evidence for a different layer.
|
||||||
|
|
||||||
|
Primary invariants:
|
||||||
|
|
||||||
|
- SQLite is authoritative desired state.
|
||||||
|
- Linux kernel state is live state.
|
||||||
|
- Reconciliation is deterministic and idempotent.
|
||||||
|
- Server-side WireGuard peer `AllowedIPs` represent cryptokey routing, not client routing policy.
|
||||||
|
- Client-side `AllowedIPs` represent the client's routing policy.
|
||||||
|
- NAT and forwarding must operate on real packets, not merely generated nftables rules.
|
||||||
|
- Live peer status must be derived from kernel telemetry.
|
||||||
|
- Secrets must never leak through logs, CLI output, API responses, or test artifacts.
|
||||||
|
|
||||||
|
## 2. Release Quality Gates
|
||||||
|
|
||||||
|
| Gate | Command / Evidence | Requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| Formatting | `cargo fmt --all -- --check` | PASS |
|
||||||
|
| Compilation | `cargo check --workspace --all-targets` | PASS |
|
||||||
|
| Lint | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | PASS |
|
||||||
|
| Workspace tests | `cargo test --workspace` | All tests PASS |
|
||||||
|
| Release build | `cargo build --release --workspace` | PASS |
|
||||||
|
| CLI suite | `LIVE=0 bash scripts/test-cli-comprehensive.sh` | PASS |
|
||||||
|
| Native integration | `LIVE=0 bash scripts/test-native-integration.sh` | PASS |
|
||||||
|
| Kernel suite | `LIVE=0 bash scripts/test-live-kernel.sh` | PASS in SAFE baseline; LIVE acceptance requires dedicated host |
|
||||||
|
| Diff hygiene | `git diff --check` | PASS |
|
||||||
|
| Physical client | Android WireGuard acceptance | Required for production VPN certification |
|
||||||
|
|
||||||
|
## 3. Workspace Unit Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
Focused crates may be run independently:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p nx9-wg-core
|
||||||
|
cargo test -p nx9-wireguard
|
||||||
|
cargo test -p nx9-wg-api
|
||||||
|
cargo test -p nx9-wg-db
|
||||||
|
cargo test -p nx9-wg-network
|
||||||
|
cargo test -p nx9-wg-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Formatting, Compilation & Clippy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo check --workspace --all-targets
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
cargo build --release --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
No release is accepted with formatting drift, compiler warnings promoted by `-D warnings`, or a non-reproducible release build.
|
||||||
|
|
||||||
|
## 5. Core Domain & Validation Tests
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- Interface identity and CIDR validation.
|
||||||
|
- Peer tunnel address validation.
|
||||||
|
- Full-tunnel, split-tunnel, and custom client profiles.
|
||||||
|
- Server/client `AllowedIPs` semantic separation.
|
||||||
|
- Non-overlapping server-side cryptokey routes.
|
||||||
|
- MTU validation.
|
||||||
|
- Endpoint validation.
|
||||||
|
- Interface-name uniqueness.
|
||||||
|
- Key preservation during interface edits.
|
||||||
|
|
||||||
|
## 6. WireGuard Engine Tests
|
||||||
|
|
||||||
|
The WireGuard engine must validate:
|
||||||
|
|
||||||
|
- Interface creation and deletion.
|
||||||
|
- Private/public key configuration.
|
||||||
|
- Listen port and MTU.
|
||||||
|
- Peer creation/update/removal.
|
||||||
|
- `ReplaceAllowedIps` behavior.
|
||||||
|
- Server-side peer routes derived from assigned addresses.
|
||||||
|
- Learned endpoint and handshake telemetry.
|
||||||
|
- Idempotent synchronization.
|
||||||
|
|
||||||
|
For a road-warrior peer such as `10.100.0.9/32`, the Linux kernel peer must receive `10.100.0.9/32`, not the client's `0.0.0.0/0` full-tunnel route.
|
||||||
|
|
||||||
|
## 7. Client Configuration & QR Tests
|
||||||
|
|
||||||
|
Verify generated client configuration:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Interface]
|
||||||
|
Address = 10.100.0.9/32
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
AllowedIPs = 0.0.0.0/0
|
||||||
|
Endpoint = <configured-server-endpoint>:51820
|
||||||
|
```
|
||||||
|
|
||||||
|
For IPv4-only server interfaces, do not silently export `::/0` unless IPv6 service is actually configured and intended.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- Explicit endpoint override wins.
|
||||||
|
- Persistent `server_endpoint` setting is consumed automatically.
|
||||||
|
- Endpoint includes a valid UDP port.
|
||||||
|
- QR generation is equivalent to exported configuration.
|
||||||
|
- Terminal QR rendering works.
|
||||||
|
- WebUI QR rendering is present and scannable.
|
||||||
|
|
||||||
|
## 8. REST API Tests
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- Interface CRUD.
|
||||||
|
- Interface editing.
|
||||||
|
- Peer CRUD.
|
||||||
|
- Peer telemetry enrichment.
|
||||||
|
- Server endpoint settings.
|
||||||
|
- QR/config export.
|
||||||
|
- Reconciliation endpoints.
|
||||||
|
- Diagnostics.
|
||||||
|
- Authentication and authorization.
|
||||||
|
- Invalid input and conflict responses.
|
||||||
|
|
||||||
|
Telemetry responses must expose current kernel-derived endpoint, handshake, RX, and TX values where available.
|
||||||
|
|
||||||
|
## 9. WebUI Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- Interface list displays existing interfaces.
|
||||||
|
- Interface Edit action exists.
|
||||||
|
- Edit form preserves public identity and does not regenerate keys.
|
||||||
|
- Peer list displays tunnel address and learned endpoint.
|
||||||
|
- QR action is available.
|
||||||
|
- Endpoint configuration is visible in Settings.
|
||||||
|
- Refresh Telemetry obtains fresh kernel state.
|
||||||
|
- Status states are truthful.
|
||||||
|
|
||||||
|
Status semantics:
|
||||||
|
|
||||||
|
| State | Condition |
|
||||||
|
|---|---|
|
||||||
|
| Connected | Active peer with handshake age < 180 seconds |
|
||||||
|
| Awaiting Handshake | Active peer with no observed handshake |
|
||||||
|
| Disconnected | Active peer with handshake age >= 180 seconds |
|
||||||
|
| Disabled | Peer disabled |
|
||||||
|
| Expired | Peer expired |
|
||||||
|
| Revoked | Peer revoked |
|
||||||
|
|
||||||
|
## 10. Timestamp & Telemetry Tests
|
||||||
|
|
||||||
|
Backend timestamps must carry an explicit UTC offset. Browser parsing must not reinterpret UTC database timestamps as local wall-clock timestamps.
|
||||||
|
|
||||||
|
Test:
|
||||||
|
|
||||||
|
- Never-handshaken peer.
|
||||||
|
- Fresh handshake.
|
||||||
|
- Handshake exactly around the 180-second boundary.
|
||||||
|
- Stale handshake.
|
||||||
|
- RX/TX counters increasing.
|
||||||
|
- Learned endpoint changing due to roaming.
|
||||||
|
- SQLite telemetry cache update.
|
||||||
|
|
||||||
|
## 11. Reconciliation Tests
|
||||||
|
|
||||||
|
Required lifecycle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Desired SQLite State
|
||||||
|
↓
|
||||||
|
Reconciliation Plan
|
||||||
|
↓
|
||||||
|
Native Linux Engines
|
||||||
|
↓
|
||||||
|
Kernel State
|
||||||
|
↓
|
||||||
|
Live Telemetry
|
||||||
|
↓
|
||||||
|
Zero Drift
|
||||||
|
```
|
||||||
|
|
||||||
|
Test deliberate drift in:
|
||||||
|
|
||||||
|
- Interface address.
|
||||||
|
- Interface MTU.
|
||||||
|
- Interface listen port.
|
||||||
|
- Peer server-side `AllowedIPs`.
|
||||||
|
- Peer keepalive.
|
||||||
|
- Routes.
|
||||||
|
- Firewall/NAT state.
|
||||||
|
|
||||||
|
For every mutation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nx9-wg reconcile plan
|
||||||
|
sudo nx9-wg reconcile apply
|
||||||
|
sudo nx9-wg reconcile plan
|
||||||
|
```
|
||||||
|
|
||||||
|
The final plan must report zero drift.
|
||||||
|
|
||||||
|
## 12. Network & Routing Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- `10.100.0.0/24 dev wg0` exists when `10.100.0.1/24` is assigned.
|
||||||
|
- Peer `/32` routes resolve through `wg0`.
|
||||||
|
- No unintended default-route replacement occurs.
|
||||||
|
- Existing LAN routes remain intact.
|
||||||
|
- Route deletion/recreation converges safely.
|
||||||
|
- Split-tunnel routes remain distinct from full-tunnel client routing.
|
||||||
|
|
||||||
|
## 13. Forwarding Tests
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nx9-wg live forwarding --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected IPv4 forwarding is enabled for a full-tunnel road-warrior deployment.
|
||||||
|
|
||||||
|
Where IPv6 is not configured, IPv6 forwarding must not create an accidental blackhole or misleading client configuration.
|
||||||
|
|
||||||
|
## 14. Firewall & NAT Tests
|
||||||
|
|
||||||
|
The managed nftables table is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
table inet nx9_wg
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- Atomic ruleset application.
|
||||||
|
- Forward chain behavior.
|
||||||
|
- Established/related traffic handling.
|
||||||
|
- NAT masquerade scoped to `10.100.0.0/24`.
|
||||||
|
- No unrelated nftables table is flushed.
|
||||||
|
- Packet counters increase during real client traffic.
|
||||||
|
|
||||||
|
A successful ruleset-generation test is not equivalent to packet-level NAT validation.
|
||||||
|
|
||||||
|
## 15. SAFE Mode Testing (`LIVE=0`)
|
||||||
|
|
||||||
|
SAFE mode is the default for developer workstations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIVE=0 bash scripts/test-cli-comprehensive.sh
|
||||||
|
LIVE=0 bash scripts/test-native-integration.sh
|
||||||
|
LIVE=0 bash scripts/test-live-kernel.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
SAFE mode validates control-plane logic, parsers, deterministic builders, simulations, and read-only kernel inspection without intentionally mutating production networking.
|
||||||
|
|
||||||
|
## 16. LIVE Kernel Testing (`LIVE=1`)
|
||||||
|
|
||||||
|
LIVE testing requires a dedicated disposable Linux host or VM with appropriate privileges.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -E LIVE=1 bash scripts/test-native-integration.sh
|
||||||
|
sudo -E LIVE=1 bash scripts/test-live-kernel.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The test harness must:
|
||||||
|
|
||||||
|
- Capture a baseline.
|
||||||
|
- Use isolated test resources.
|
||||||
|
- Avoid changing default routes.
|
||||||
|
- Avoid modifying unrelated nftables tables.
|
||||||
|
- Restore forwarding state.
|
||||||
|
- Remove only resources created by the test.
|
||||||
|
- Compare post-test state against baseline.
|
||||||
|
|
||||||
|
## 17. Comprehensive CLI Suite
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIVE=0 bash scripts/test-cli-comprehensive.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite covers CLI commands, help, output formats, authentication, profiles, interfaces, peers, routes, firewall, NAT, forwarding, reconciliation, backup, audit, live inspection, diagnostics, environment handling, explicit database paths, and source-level safety checks.
|
||||||
|
|
||||||
|
The documented baseline is **203 passed / 7 skipped**; regenerate the count after any test changes.
|
||||||
|
|
||||||
|
## 18. Native Integration Suite
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIVE=0 bash scripts/test-native-integration.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The documented baseline is **19 passed / 1 skipped**.
|
||||||
|
|
||||||
|
The suite validates the desired-state-to-native-engine-to-kernel architecture, drift injection, convergence, and diagnostic evidence.
|
||||||
|
|
||||||
|
## 19. Dedicated Live-Kernel Suite
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIVE=0 bash scripts/test-live-kernel.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The documented SAFE baseline is **23 passed / 1 skipped**. A true production release should additionally capture a `LIVE=1` evidence run on the intended Linux platform.
|
||||||
|
|
||||||
|
## 20. Security, Secrets & Script Safety
|
||||||
|
|
||||||
|
Audit requirements:
|
||||||
|
|
||||||
|
- No plaintext private keys in normal human-readable diagnostics.
|
||||||
|
- No passwords in logs.
|
||||||
|
- No preshared keys in normal status output.
|
||||||
|
- No token secrets in logs or test output.
|
||||||
|
- No production subprocess execution from the Rust control plane.
|
||||||
|
- Scripts use strict shell options and bounded cleanup.
|
||||||
|
- Live tests use isolated temporary resources.
|
||||||
|
- Installer/uninstaller operations are explicit and privilege-aware.
|
||||||
|
|
||||||
|
The repository scripts are:
|
||||||
|
|
||||||
|
- `scripts/install.sh`
|
||||||
|
- `scripts/package-release.sh`
|
||||||
|
- `scripts/test-cli-comprehensive.sh`
|
||||||
|
- `scripts/test-cli-live.sh`
|
||||||
|
- `scripts/test-live-kernel.sh`
|
||||||
|
- `scripts/test-native-integration.sh`
|
||||||
|
- `scripts/uninstall.sh`
|
||||||
|
|
||||||
|
## 21. Physical Android / Road-Warrior Acceptance
|
||||||
|
|
||||||
|
A real WireGuard client is mandatory evidence for production VPN certification.
|
||||||
|
|
||||||
|
Minimum test:
|
||||||
|
|
||||||
|
1. Generate QR/config for a test peer.
|
||||||
|
2. Import into the official Android WireGuard client.
|
||||||
|
3. Connect over LAN first.
|
||||||
|
4. Verify `ping 10.100.0.1`.
|
||||||
|
5. Verify Internet IP connectivity such as `ping 1.1.1.1`.
|
||||||
|
6. Verify DNS resolution.
|
||||||
|
7. Load an HTTPS page.
|
||||||
|
8. Confirm server live telemetry reports endpoint, handshake, RX, and TX.
|
||||||
|
9. Disconnect and verify stale-state transition.
|
||||||
|
10. Reconnect and verify telemetry refresh.
|
||||||
|
|
||||||
|
For WAN road-warrior certification:
|
||||||
|
|
||||||
|
1. Disable Android Wi-Fi.
|
||||||
|
2. Use 4G/5G cellular data.
|
||||||
|
3. Configure the public server endpoint.
|
||||||
|
4. Forward UDP 51820 to the NX9-WG host.
|
||||||
|
5. Verify handshake, tunnel reachability, DNS, and full-tunnel Internet.
|
||||||
|
|
||||||
|
## 22. Release Acceptance Matrix
|
||||||
|
|
||||||
|
| Acceptance Gate | v1.0.0 Evidence Status |
|
||||||
|
|---|---|
|
||||||
|
| Real Android handshake | **PASS — operator verified** |
|
||||||
|
| Tunnel control connectivity | **PASS — operator verified** |
|
||||||
|
| Full-tunnel Internet | **PASS — operator verified** |
|
||||||
|
| NAT/forwarding data plane | **PASS — operator verified through working Internet path; packet-counter evidence should be retained for formal audit** |
|
||||||
|
| Disconnect/reconnect status | **PASS — implementation verified; retain physical transition evidence for audit** |
|
||||||
|
| Interface editing | **PASS — operator verified** |
|
||||||
|
| Persistent server endpoint / QR | **PASS — operator verified** |
|
||||||
|
| WebUI live peer status | **PASS — operator verified with connected mobile client** |
|
||||||
|
| Final reconciliation | **PASS — zero drift observed** |
|
||||||
|
| IPv6 safety | **PASS — code/config validation; live IPv6 remains deployment-specific** |
|
||||||
|
| External cellular/WAN road-warrior | **NOT VERIFIED unless separately executed and recorded** |
|
||||||
|
| Post-reboot physical-client persistence | **NOT VERIFIED unless separately executed and recorded** |
|
||||||
|
|
||||||
|
**Release rule:** Do not convert an unexecuted physical gate into PASS merely because simulated or unit tests pass.
|
||||||
|
|
||||||
|
## 23. Server Reboot Acceptance
|
||||||
|
|
||||||
|
On the actual deployment host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
After boot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status nx9-wg --no-pager
|
||||||
|
sudo nx9-wg live interface show wg0 --json
|
||||||
|
sudo nx9-wg live peer list wg0 --json
|
||||||
|
sudo nx9-wg reconcile plan
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify that desired state reconstructs:
|
||||||
|
|
||||||
|
- `wg0`.
|
||||||
|
- Interface address.
|
||||||
|
- Listen port and MTU.
|
||||||
|
- Server-side peer `/32` routes.
|
||||||
|
- Forwarding.
|
||||||
|
- nftables/NAT.
|
||||||
|
- WebUI/API availability.
|
||||||
|
|
||||||
|
Then reconnect a physical client and repeat the data-plane acceptance.
|
||||||
|
|
||||||
|
## 24. Release Packaging Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --workspace
|
||||||
|
bash scripts/package-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
- Package name contains `v1.0.0`.
|
||||||
|
- Binary reports `1.0.0`.
|
||||||
|
- README and CHANGELOG are included.
|
||||||
|
- `docs/TESTING.md` is included.
|
||||||
|
- Installation scripts are executable.
|
||||||
|
- Archive extracts into an isolated directory.
|
||||||
|
- SHA-256 manifest matches the generated archives.
|
||||||
|
|
||||||
|
## 25. Version & Repository Consistency Audit
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo metadata --no-deps --format-version 1
|
||||||
|
./target/release/nx9-wg --version
|
||||||
|
grep -RIn --exclude-dir=.git 'v0.8.0\|0.8.0' .
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 26. Final Release Command Set
|
||||||
|
|
||||||
|
The minimum final gate is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo check --workspace --all-targets
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
cargo build --release --workspace
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Then execute the appropriate SAFE and LIVE suites, followed by physical client acceptance and package verification.
|
||||||
|
|
||||||
|
## 27. Evidence Retention
|
||||||
|
|
||||||
|
For a formal release record, retain:
|
||||||
|
|
||||||
|
- Exact commit ID.
|
||||||
|
- `cargo test --workspace` output.
|
||||||
|
- CLI/integration/kernel test summaries.
|
||||||
|
- `nx9-wg --version` output.
|
||||||
|
- `systemctl status nx9-wg` output.
|
||||||
|
- Live WireGuard interface/peer JSON.
|
||||||
|
- Reconciliation plan output.
|
||||||
|
- Android handshake and Internet verification evidence.
|
||||||
|
- WAN/cellular evidence where performed.
|
||||||
|
- Reboot evidence where performed.
|
||||||
|
- Release archive SHA-256 checksums.
|
||||||
|
|
||||||
|
This evidence separates **software correctness**, **kernel integration correctness**, and **real-world VPN data-plane correctness**.
|
||||||
+2
-2
@@ -128,5 +128,5 @@ nx9-wg init --password-file /run/secrets/admin_pw
|
|||||||
- `nx9-wg live nftables`: Query active `table inet nx9_wg` ruleset.
|
- `nx9-wg live nftables`: Query active `table inet nx9_wg` ruleset.
|
||||||
|
|
||||||
### 17. `diagnostics`
|
### 17. `diagnostics`
|
||||||
- `nx9-wg diagnostics inspect all`: Inspect health across all 9 subsystems.
|
- `nx9-wg diagnostics all`: Inspect health across all 9 subsystems.
|
||||||
- `nx9-wg diagnostics inspect <SUBSYSTEM>`: Inspect specific subsystem (`system`, `network`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `reconciliation`).
|
- `nx9-wg diagnostics <SUBSYSTEM>`: Inspect specific subsystem (`system`, `network`, `wireguard`, `peer`, `routing`, `forwarding`, `firewall`, `nat`, `reconciliation`).
|
||||||
@@ -23,8 +23,8 @@ Download and extract the official release archive:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Download release archive (replace with current version/arch)
|
# 1. Download release archive (replace with current version/arch)
|
||||||
tar -xzf nx9-wg-v0.8.0-linux-x86_64.tar.gz
|
tar -xzf nx9-wg-v1.0.0-linux-x86_64.tar.gz
|
||||||
cd nx9-wg-v0.8.0-linux-x86_64
|
cd nx9-wg-v1.0.0-linux-x86_64
|
||||||
|
|
||||||
# 2. Run the automated installer as root
|
# 2. Run the automated installer as root
|
||||||
sudo bash install.sh
|
sudo bash install.sh
|
||||||
@@ -94,7 +94,7 @@ sudo systemctl status nx9-wg
|
|||||||
### Step 3.2 — Check Operational Health via CLI
|
### Step 3.2 — Check Operational Health via CLI
|
||||||
```bash
|
```bash
|
||||||
sudo /usr/local/bin/nx9-wg system health
|
sudo /usr/local/bin/nx9-wg system health
|
||||||
sudo /usr/local/bin/nx9-wg diagnostics inspect all
|
sudo /usr/local/bin/nx9-wg diagnostics all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3.3 — Log in via Web User Interface
|
### Step 3.3 — Log in via Web User Interface
|
||||||
|
|||||||
+14
-9
@@ -13,24 +13,29 @@ bash scripts/package-release.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Packaging Outputs in `target/dist/`:
|
### Packaging Outputs in `target/dist/`:
|
||||||
- `nx9-wg-v0.8.0-linux-x86_64.tar.gz` (Standard gzip archive)
|
- `nx9-wg-v1.0.0-linux-x86_64.tar.gz` (Standard gzip archive)
|
||||||
- `nx9-wg-v0.8.0-linux-x86_64.tar.xz` (High-compression XZ archive)
|
- `nx9-wg-v1.0.0-linux-x86_64.tar.xz` (High-compression XZ archive)
|
||||||
- `nx9-wg-v0.8.0-linux-x86_64.sha256` (Cryptographic SHA-256 checksums)
|
- `nx9-wg-v1.0.0-linux-x86_64.sha256` (Cryptographic SHA-256 checksums)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Release Archive Contents
|
## 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.
|
||||||
|
|
||||||
|
## 3. Release Archive Contents
|
||||||
|
|
||||||
Every release archive contains everything required for a standalone, offline production deployment:
|
Every release archive contains everything required for a standalone, offline production deployment:
|
||||||
|
|
||||||
```
|
```
|
||||||
nx9-wg-v0.8.0-linux-x86_64/
|
nx9-wg-v1.0.0-linux-x86_64/
|
||||||
├── nx9-wg (Native executable binary, mode 0755)
|
├── nx9-wg (Native executable binary, mode 0755)
|
||||||
├── nx9-wg.service (Hardened systemd unit file, mode 0644)
|
├── nx9-wg.service (Hardened systemd unit file, mode 0644)
|
||||||
├── config.example.toml (Production configuration template, mode 0644)
|
├── config.example.toml (Production configuration template, mode 0644)
|
||||||
├── install.sh (Automated production installer, mode 0755)
|
├── install.sh (Automated production installer, mode 0755)
|
||||||
├── uninstall.sh (Safe uninstallation script, mode 0755)
|
├── uninstall.sh (Safe uninstallation script, mode 0755)
|
||||||
├── README.md (Primary project guide)
|
├── README.md (Primary project guide)
|
||||||
|
├── CHANGELOG.md (Release history)
|
||||||
├── LICENSE-MIT (MIT License text)
|
├── LICENSE-MIT (MIT License text)
|
||||||
├── LICENSE-APACHE (Apache 2.0 License text)
|
├── LICENSE-APACHE (Apache 2.0 License text)
|
||||||
└── docs/ (Complete offline documentation suite)
|
└── docs/ (Complete offline documentation suite)
|
||||||
@@ -38,7 +43,7 @@ nx9-wg-v0.8.0-linux-x86_64/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Standalone Verification Invariant
|
## 4. Standalone Verification Invariant
|
||||||
|
|
||||||
Release packages must function completely independently of the source repository. When extracted into an isolated clean directory (`/tmp/nx9-release-verify...`):
|
Release packages must function completely independently of the source repository. When extracted into an isolated clean directory (`/tmp/nx9-release-verify...`):
|
||||||
- `nx9-wg version` outputs valid version, architecture, and platform strings.
|
- `nx9-wg version` outputs valid version, architecture, and platform strings.
|
||||||
@@ -48,7 +53,7 @@ Release packages must function completely independently of the source repository
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Production Filesystem Layout
|
## 5. Production Filesystem Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
/usr/local/bin/nx9-wg (0755 root:root - Binary)
|
/usr/local/bin/nx9-wg (0755 root:root - Binary)
|
||||||
@@ -67,7 +72,7 @@ Release packages must function completely independently of the source repository
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Systemd Security Sandboxing
|
## 6. Systemd Security Sandboxing
|
||||||
|
|
||||||
The production service unit (`nx9-wg.service`) enforces modern Linux security directives:
|
The production service unit (`nx9-wg.service`) enforces modern Linux security directives:
|
||||||
|
|
||||||
@@ -79,7 +84,7 @@ The production service unit (`nx9-wg.service`) enforces modern Linux security di
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Upgrade and Rollback Sequence
|
## 7. Upgrade and Rollback Sequence
|
||||||
|
|
||||||
### Mandatory Upgrade Flow
|
### Mandatory Upgrade Flow
|
||||||
1. **Pre-Upgrade Backup**: `nx9-wg backup create --description "Pre-upgrade checkpoint"`
|
1. **Pre-Upgrade Backup**: `nx9-wg backup create --description "Pre-upgrade checkpoint"`
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=NX9 WireGuard Appliance Management Engine
|
Description=NX9 WireGuard Appliance Management Engine
|
||||||
Documentation=https://github.com/nx9/nx9-wg
|
Documentation=https://github.com/thakares/nx9-wg
|
||||||
After=network.target network-online.target
|
After=network.target network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
VERSION="$(grep -m 1 '^version = ' "${ROOT_DIR}/Cargo.toml" | cut -d '"' -f 2)"
|
VERSION="$(sed -n '/^[[:space:]]*\[workspace\.package\]/,/^[[:space:]]*\[/ { /^[[:space:]]*version[[:space:]]*=/ { s/.*= *"\([^"]*\)".*/\1/p; q; } }' "${ROOT_DIR}/Cargo.toml")"
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
echo "ERROR: unable to determine workspace package version from Cargo.toml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
ARCH="$(uname -m)"
|
ARCH="$(uname -m)"
|
||||||
OS="linux"
|
OS="linux"
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ STAGE_DIR="${DIST_DIR}/${PACKAGE_NAME}"
|
|||||||
|
|
||||||
echo "================================================================="
|
echo "================================================================="
|
||||||
echo " Packaging nx9-wg Release: ${PACKAGE_NAME}"
|
echo " Packaging nx9-wg Release: ${PACKAGE_NAME}"
|
||||||
|
echo " Workspace version: ${VERSION}"
|
||||||
echo "================================================================="
|
echo "================================================================="
|
||||||
|
|
||||||
# 1. Ensure release binary is compiled
|
# 1. Ensure release binary is compiled
|
||||||
@@ -44,6 +49,7 @@ install -m 0755 "${ROOT_DIR}/scripts/uninstall.sh" "${STAGE_DIR}/uninstall.sh"
|
|||||||
install -m 0644 "${ROOT_DIR}/README.md" "${STAGE_DIR}/README.md"
|
install -m 0644 "${ROOT_DIR}/README.md" "${STAGE_DIR}/README.md"
|
||||||
install -m 0644 "${ROOT_DIR}/LICENSE-MIT" "${STAGE_DIR}/LICENSE-MIT"
|
install -m 0644 "${ROOT_DIR}/LICENSE-MIT" "${STAGE_DIR}/LICENSE-MIT"
|
||||||
install -m 0644 "${ROOT_DIR}/LICENSE-APACHE" "${STAGE_DIR}/LICENSE-APACHE"
|
install -m 0644 "${ROOT_DIR}/LICENSE-APACHE" "${STAGE_DIR}/LICENSE-APACHE"
|
||||||
|
install -m 0644 "${ROOT_DIR}/CHANGELOG.md" "${STAGE_DIR}/CHANGELOG.md"
|
||||||
|
|
||||||
# Copy documentation
|
# Copy documentation
|
||||||
cp -r "${ROOT_DIR}/docs/"* "${STAGE_DIR}/docs/"
|
cp -r "${ROOT_DIR}/docs/"* "${STAGE_DIR}/docs/"
|
||||||
|
|||||||
+65
-12
@@ -23,9 +23,11 @@ use nx9_wg_core::validation::{
|
|||||||
validate_port_spec,
|
validate_port_spec,
|
||||||
};
|
};
|
||||||
use nx9_wg_db::Store;
|
use nx9_wg_db::Store;
|
||||||
|
#[allow(unused_imports)]
|
||||||
use nx9_wg_network::{
|
use nx9_wg_network::{
|
||||||
IpForwardingStatus, NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine,
|
IpForwardingStatus, NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine,
|
||||||
};
|
};
|
||||||
|
#[allow(unused_imports)]
|
||||||
use nx9_wireguard::{
|
use nx9_wireguard::{
|
||||||
ClientConfigBuilder, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine, WireGuardEngine,
|
ClientConfigBuilder, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine, WireGuardEngine,
|
||||||
generate_qr_ascii, generate_qr_png_bytes, generate_qr_svg,
|
generate_qr_ascii, generate_qr_png_bytes, generate_qr_svg,
|
||||||
@@ -509,6 +511,8 @@ enum PeerSubcommands {
|
|||||||
mtu: Option<u16>,
|
mtu: Option<u16>,
|
||||||
#[arg(long, help = "Explicit client profile ID")]
|
#[arg(long, help = "Explicit client profile ID")]
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
|
#[arg(long, help = "WireGuard server endpoint host or IP")]
|
||||||
|
endpoint: Option<String>,
|
||||||
#[arg(short, long, help = "Write configuration to file")]
|
#[arg(short, long, help = "Write configuration to file")]
|
||||||
output: Option<PathBuf>,
|
output: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
@@ -533,6 +537,8 @@ enum PeerSubcommands {
|
|||||||
mtu: Option<u16>,
|
mtu: Option<u16>,
|
||||||
#[arg(long, help = "Explicit client profile ID")]
|
#[arg(long, help = "Explicit client profile ID")]
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
|
#[arg(long, help = "WireGuard server endpoint host or IP")]
|
||||||
|
endpoint: Option<String>,
|
||||||
#[arg(
|
#[arg(
|
||||||
long = "qr-format",
|
long = "qr-format",
|
||||||
default_value = "terminal",
|
default_value = "terminal",
|
||||||
@@ -932,6 +938,28 @@ enum LivePeerSubcommands {
|
|||||||
Show { id: String },
|
Show { id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_wireguard_engine() -> Arc<dyn WireGuardEngine> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
Arc::new(NativeLinuxWireGuardEngine::new())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
Arc::new(SimulatedWireGuardEngine::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_network_engine() -> Arc<dyn NetworkEngine> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
Arc::new(NativeLinuxNetworkEngine::new())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
Arc::new(SimulatedNetworkEngine::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Output Formatter ────────────────────────────────────────────────────────
|
// ── Output Formatter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn print_output<T: Serialize>(
|
fn print_output<T: Serialize>(
|
||||||
@@ -1712,7 +1740,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!("Interface '{interface}' disabled.");
|
println!("Interface '{interface}' disabled.");
|
||||||
}
|
}
|
||||||
InterfaceSubcommands::Status { interface } => {
|
InterfaceSubcommands::Status { interface } => {
|
||||||
let wg = SimulatedWireGuardEngine::new();
|
let wg = create_wireguard_engine();
|
||||||
let stats = wg.get_interface_stats(&interface).await?;
|
let stats = wg.get_interface_stats(&interface).await?;
|
||||||
match stats {
|
match stats {
|
||||||
Some(s) => print_output(&s, format)?,
|
Some(s) => print_output(&s, format)?,
|
||||||
@@ -1721,8 +1749,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
InterfaceSubcommands::Reconcile { interface: _ } => {
|
InterfaceSubcommands::Reconcile { interface: _ } => {
|
||||||
let state = AppState::new(store);
|
let state = AppState::new(store);
|
||||||
let wg = Arc::new(SimulatedWireGuardEngine::new());
|
let wg = create_wireguard_engine();
|
||||||
let net = Arc::new(SimulatedNetworkEngine::new());
|
let net = create_network_engine();
|
||||||
let reconciler = ReconciliationEngine::new(state, wg, net);
|
let reconciler = ReconciliationEngine::new(state, wg, net);
|
||||||
let report = reconciler.apply().await?;
|
let report = reconciler.apply().await?;
|
||||||
print_output(&report, format)?;
|
print_output(&report, format)?;
|
||||||
@@ -1988,7 +2016,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.get_interface(peer.interface_id)
|
.get_interface(peer.interface_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or("Interface not found")?;
|
.ok_or("Interface not found")?;
|
||||||
let wg = SimulatedWireGuardEngine::new();
|
let wg = create_wireguard_engine();
|
||||||
let iface_stats = wg.get_interface_stats(&iface.name).await?;
|
let iface_stats = wg.get_interface_stats(&iface.name).await?;
|
||||||
let peer_stat = iface_stats.and_then(|s| {
|
let peer_stat = iface_stats.and_then(|s| {
|
||||||
s.peers
|
s.peers
|
||||||
@@ -2008,6 +2036,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
nat,
|
nat,
|
||||||
mtu,
|
mtu,
|
||||||
profile,
|
profile,
|
||||||
|
endpoint,
|
||||||
output,
|
output,
|
||||||
} => {
|
} => {
|
||||||
let peer_id = Uuid::parse_str(&id)?;
|
let peer_id = Uuid::parse_str(&id)?;
|
||||||
@@ -2060,10 +2089,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
None
|
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 conf = ClientConfigBuilder::build_with_profile(
|
let conf = ClientConfigBuilder::build_with_profile(
|
||||||
&peer,
|
&peer,
|
||||||
&iface,
|
&iface,
|
||||||
"127.0.0.1",
|
&server_host,
|
||||||
resolved_profile.as_ref(),
|
resolved_profile.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
if let Some(out_path) = output {
|
if let Some(out_path) = output {
|
||||||
@@ -2081,6 +2120,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
nat,
|
nat,
|
||||||
mtu,
|
mtu,
|
||||||
profile,
|
profile,
|
||||||
|
endpoint,
|
||||||
qr_format,
|
qr_format,
|
||||||
} => {
|
} => {
|
||||||
let peer_id = Uuid::parse_str(&id)?;
|
let peer_id = Uuid::parse_str(&id)?;
|
||||||
@@ -2133,10 +2173,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
None
|
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 conf = ClientConfigBuilder::build_with_profile(
|
let conf = ClientConfigBuilder::build_with_profile(
|
||||||
&peer,
|
&peer,
|
||||||
&iface,
|
&iface,
|
||||||
"127.0.0.1",
|
&server_host,
|
||||||
resolved_profile.as_ref(),
|
resolved_profile.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
match qr_format.to_lowercase().as_str() {
|
match qr_format.to_lowercase().as_str() {
|
||||||
@@ -2778,12 +2828,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Commands::Live(args) => match args.subcommand {
|
Commands::Live(args) => match args.subcommand {
|
||||||
LiveSubcommands::Interface(i_args) => match i_args.subcommand {
|
LiveSubcommands::Interface(i_args) => match i_args.subcommand {
|
||||||
LiveInterfaceSubcommands::List => {
|
LiveInterfaceSubcommands::List => {
|
||||||
let wg = SimulatedWireGuardEngine::new();
|
let wg = create_wireguard_engine();
|
||||||
let list = wg.list_interfaces().await?;
|
let list = wg.list_interfaces().await?;
|
||||||
print_output(&list, format)?;
|
print_output(&list, format)?;
|
||||||
}
|
}
|
||||||
LiveInterfaceSubcommands::Show { name } => {
|
LiveInterfaceSubcommands::Show { name } => {
|
||||||
let wg = SimulatedWireGuardEngine::new();
|
let wg = create_wireguard_engine();
|
||||||
let stats = wg.get_interface_stats(&name).await?;
|
let stats = wg.get_interface_stats(&name).await?;
|
||||||
match stats {
|
match stats {
|
||||||
Some(s) => print_output(&s, format)?,
|
Some(s) => print_output(&s, format)?,
|
||||||
@@ -2796,13 +2846,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
},
|
},
|
||||||
LiveSubcommands::Peer(p_args) => match p_args.subcommand {
|
LiveSubcommands::Peer(p_args) => match p_args.subcommand {
|
||||||
LivePeerSubcommands::List { interface } => {
|
LivePeerSubcommands::List { interface } => {
|
||||||
let wg = SimulatedWireGuardEngine::new();
|
let wg = create_wireguard_engine();
|
||||||
let stats = wg.get_interface_stats(&interface).await?;
|
let stats = wg.get_interface_stats(&interface).await?;
|
||||||
let peers = stats.map(|s| s.peers).unwrap_or_default();
|
let peers = stats.map(|s| s.peers).unwrap_or_default();
|
||||||
print_output(&peers, format)?;
|
print_output(&peers, format)?;
|
||||||
}
|
}
|
||||||
LivePeerSubcommands::Show { id } => {
|
LivePeerSubcommands::Show { id } => {
|
||||||
let wg = SimulatedWireGuardEngine::new();
|
let wg = create_wireguard_engine();
|
||||||
let ifaces = wg.list_interfaces().await?;
|
let ifaces = wg.list_interfaces().await?;
|
||||||
let mut found = None;
|
let mut found = None;
|
||||||
for iface in ifaces {
|
for iface in ifaces {
|
||||||
@@ -2835,7 +2885,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
print_output(&status, format)?;
|
print_output(&status, format)?;
|
||||||
}
|
}
|
||||||
LiveSubcommands::Firewall => {
|
LiveSubcommands::Firewall => {
|
||||||
let net = NativeLinuxNetworkEngine::new();
|
let net = create_network_engine();
|
||||||
let ruleset = net.get_active_nftables_ruleset().await?;
|
let ruleset = net.get_active_nftables_ruleset().await?;
|
||||||
print_output(&ruleset, format)?;
|
print_output(&ruleset, format)?;
|
||||||
}
|
}
|
||||||
@@ -2844,8 +2894,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
print_output(&status, format)?;
|
print_output(&status, format)?;
|
||||||
}
|
}
|
||||||
LiveSubcommands::Nat => {
|
LiveSubcommands::Nat => {
|
||||||
|
let net = create_network_engine();
|
||||||
|
let ruleset = net.get_active_nftables_ruleset().await.unwrap_or_default();
|
||||||
|
let nat_active = ruleset.contains("masquerade");
|
||||||
let status = serde_json::json!({
|
let status = serde_json::json!({
|
||||||
"nat_active": true,
|
"nat_masquerade_active": nat_active,
|
||||||
"table": "inet nx9_wg",
|
"table": "inet nx9_wg",
|
||||||
"chain": "postrouting"
|
"chain": "postrouting"
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,19 +274,41 @@ fn test_cli_interface_and_peer_lifecycle() {
|
|||||||
let peers: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
let peers: serde_json::Value = serde_json::from_str(&out).expect("valid json");
|
||||||
assert_eq!(peers.as_array().unwrap().len(), 1);
|
assert_eq!(peers.as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
// Peer Config
|
// Peer Config without endpoint or setting should fail with actionable error
|
||||||
let (ok, out, _) = runner.run(&["peer", "config", peer_id]);
|
let (ok, _, err) = runner.run(&["peer", "config", peer_id]);
|
||||||
|
assert!(!ok);
|
||||||
|
assert!(err.contains("No reachable WireGuard server endpoint is configured"));
|
||||||
|
|
||||||
|
// Peer Config with explicit --endpoint
|
||||||
|
let (ok, out, _) = runner.run(&["peer", "config", peer_id, "--endpoint", "vpn.example.com"]);
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
assert!(out.contains("[Interface]"));
|
assert!(out.contains("[Interface]"));
|
||||||
assert!(out.contains("[Peer]"));
|
assert!(out.contains("[Peer]"));
|
||||||
|
assert!(out.contains("Endpoint = vpn.example.com:51820"));
|
||||||
|
|
||||||
// Peer QR ASCII
|
// Peer QR ASCII
|
||||||
let (ok, out, _) = runner.run(&["peer", "qr", peer_id, "--qr-format", "terminal"]);
|
let (ok, out, _) = runner.run(&[
|
||||||
|
"peer",
|
||||||
|
"qr",
|
||||||
|
peer_id,
|
||||||
|
"--endpoint",
|
||||||
|
"vpn.example.com",
|
||||||
|
"--qr-format",
|
||||||
|
"terminal",
|
||||||
|
]);
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
assert!(!out.is_empty());
|
assert!(!out.is_empty());
|
||||||
|
|
||||||
// Peer QR SVG
|
// Peer QR SVG
|
||||||
let (ok, out, _) = runner.run(&["peer", "qr", peer_id, "--qr-format", "svg"]);
|
let (ok, out, _) = runner.run(&[
|
||||||
|
"peer",
|
||||||
|
"qr",
|
||||||
|
peer_id,
|
||||||
|
"--endpoint",
|
||||||
|
"vpn.example.com",
|
||||||
|
"--qr-format",
|
||||||
|
"svg",
|
||||||
|
]);
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
assert!(out.contains("<svg"));
|
assert!(out.contains("<svg"));
|
||||||
|
|
||||||
@@ -755,7 +777,17 @@ fn test_cli_client_profile_and_mtu_system() {
|
|||||||
assert_eq!(res_json2["mtu"], 1360);
|
assert_eq!(res_json2["mtu"], 1360);
|
||||||
assert_eq!(res_json2["applied_profile_id"], "starlink-cgnat");
|
assert_eq!(res_json2["applied_profile_id"], "starlink-cgnat");
|
||||||
|
|
||||||
// 6. Peer Config with Profile Options
|
// 6. Set server_endpoint setting
|
||||||
|
let (ok, _, _) = runner.run(&[
|
||||||
|
"system",
|
||||||
|
"settings",
|
||||||
|
"set",
|
||||||
|
"server_endpoint",
|
||||||
|
"vpn.example.com",
|
||||||
|
]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// 7. Peer Config with Profile Options
|
||||||
let (ok, out, _) = runner.run(&[
|
let (ok, out, _) = runner.run(&[
|
||||||
"peer",
|
"peer",
|
||||||
"config",
|
"config",
|
||||||
|
|||||||
Reference in new issue
Block a user