cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# NX9 WireGuard - Environment Variables Example (.env.example)
|
||||||
|
#
|
||||||
|
# All environment variables for nx9-wg MUST use the NX9_WG_ namespace prefix.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Path to the TOML configuration file
|
||||||
|
NX9_WG_CONFIG=/etc/nx9-wg/config.toml
|
||||||
|
|
||||||
|
# Directory where persistent SQLite database and state files are located
|
||||||
|
NX9_WG_DATA_DIR=/var/lib/nx9-wg
|
||||||
|
|
||||||
|
# Specific database path or sqlite connection URL
|
||||||
|
NX9_WG_DATABASE=/var/lib/nx9-wg/nx9-wg.db
|
||||||
|
|
||||||
|
# Directory where database backup archives and manifests are saved
|
||||||
|
NX9_WG_BACKUP_DIR=/var/lib/nx9-wg/backups
|
||||||
|
|
||||||
|
# Maximum number of automated backups to retain
|
||||||
|
NX9_WG_BACKUP_MAX_COUNT=10
|
||||||
|
|
||||||
|
# Optional cron schedule for automated backups (e.g. 02:00 daily: "0 2 * * *")
|
||||||
|
# NX9_WG_BACKUP_SCHEDULE="0 2 * * *"
|
||||||
|
|
||||||
|
# Host and port for the Axum REST API and WebSocket daemon
|
||||||
|
NX9_WG_LISTEN_ADDR=0.0.0.0:8080
|
||||||
|
|
||||||
|
# Logging verbosity level (trace, debug, info, warn, error)
|
||||||
|
NX9_WG_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Inactivity expiration in hours for web administrator sessions
|
||||||
|
NX9_WG_SESSION_TIMEOUT=24
|
||||||
|
|
||||||
|
# Interval in seconds between background kernel reconciliation cycles
|
||||||
|
NX9_WG_RECONCILIATION_INTERVAL=30
|
||||||
|
|
||||||
|
# Initial administrator bootstrap username (default: admin)
|
||||||
|
NX9_WG_ADMIN_USERNAME=admin
|
||||||
|
|
||||||
|
# Initial administrator bootstrap password (secret: used once at init only)
|
||||||
|
# NX9_WG_ADMIN_PASSWORD=ReplaceWithStrongPassword123!
|
||||||
|
|
||||||
|
# Path to file containing administrator bootstrap password (Docker secret / vault)
|
||||||
|
# NX9_WG_ADMIN_PASSWORD_FILE=/run/secrets/nx9_wg_admin_password
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/nx9-wg.iml" filepath="$PROJECT_DIR$/.idea/nx9-wg.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-api/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-api/tests" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-core/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-core/tests" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-db/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-db/tests" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-network/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-network/tests" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wg-ui/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wireguard/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/nx9-wireguard/tests" isTestSource="true" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+3591
File diff suppressed because it is too large.
Load diff
+103
@@ -0,0 +1,103 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/nx9-wg-core",
|
||||||
|
"crates/nx9-wg-db",
|
||||||
|
"crates/nx9-wireguard",
|
||||||
|
"crates/nx9-wg-network",
|
||||||
|
"crates/nx9-wg-api",
|
||||||
|
"crates/nx9-wg-ui",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Internal crates
|
||||||
|
nx9-wg-core = { path = "crates/nx9-wg-core" }
|
||||||
|
nx9-wg-db = { path = "crates/nx9-wg-db" }
|
||||||
|
nx9-wireguard = { path = "crates/nx9-wireguard" }
|
||||||
|
nx9-wg-network = { path = "crates/nx9-wg-network" }
|
||||||
|
nx9-wg-api = { path = "crates/nx9-wg-api" }
|
||||||
|
nx9-wg-ui = { path = "crates/nx9-wg-ui" }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# Date/time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Identifiers
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Crypto
|
||||||
|
argon2 = "0.5"
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
|
rand = "0.8"
|
||||||
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
# Network types
|
||||||
|
ipnet = { version = "2", features = ["serde"] }
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive", "env", "string"] }
|
||||||
|
|
||||||
|
# HTTP
|
||||||
|
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.7", features = ["trace", "cors", "compression-gzip", "timeout", "fs"] }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "uuid"] }
|
||||||
|
|
||||||
|
# QR
|
||||||
|
qrcode = "0.14"
|
||||||
|
image = "0.25"
|
||||||
|
|
||||||
|
# Dev
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
# ── Root binary crate ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "nx9-wg"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description = "Native Rust WireGuard management application for the NX9 ecosystem"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nx9-wg"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nx9-wg-core.workspace = true
|
||||||
|
nx9-wg-db.workspace = true
|
||||||
|
nx9-wg-api.workspace = true
|
||||||
|
nx9-wireguard.workspace = true
|
||||||
|
nx9-wg-network.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# NX9 WireGuard - Multi-Stage Container Image
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ── Stage 1: Build binary in Rust environment ────────────────────────────────
|
||||||
|
FROM rust:1.97-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/nx9-wg
|
||||||
|
|
||||||
|
# Copy manifests and source tree
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates ./crates
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build release binary with optimization
|
||||||
|
RUN cargo build --release --bin nx9-wg
|
||||||
|
|
||||||
|
# ── Stage 2: Minimal runtime image ──────────────────────────────────────────
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install only essential runtime dependencies (ca-certificates for HTTPS/TLS)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy compiled binary from builder
|
||||||
|
COPY --from=builder /usr/src/nx9-wg/target/release/nx9-wg /usr/local/bin/nx9-wg
|
||||||
|
|
||||||
|
# Create state and configuration directories
|
||||||
|
RUN mkdir -p /var/lib/nx9-wg /etc/nx9-wg /var/lib/nx9-wg/backups
|
||||||
|
|
||||||
|
# Expose API/WebSocket port and default WireGuard UDP listen port
|
||||||
|
EXPOSE 8080 51820/udp
|
||||||
|
|
||||||
|
# Environment defaults
|
||||||
|
ENV NX9_WG_DATA_DIR=/var/lib/nx9-wg
|
||||||
|
ENV NX9_WG_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Healthcheck against Axum public health endpoint
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/usr/local/bin/nx9-wg", "system", "health"] || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/nx9-wg"]
|
||||||
|
CMD ["serve", "--bind", "0.0.0.0:8080"]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# NX9 WireGuard (`nx9-wg`)
|
||||||
|
|
||||||
|
> **A native Rust, self-hosted WireGuard appliance and network management engine for the NX9 ecosystem.**
|
||||||
|
|
||||||
|
`nx9-wg` is designed from first principles as a clean, high-performance replacement for Node.js-based WireGuard managers (such as `wg-easy`). Built entirely in native Rust with zero external scripting runtime dependencies, `nx9-wg` provides authoritative SQLite persistence, robust administrative authentication, native Linux kernel networking, automated reconciliation, and pure Rust QR code and client configuration generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Native Rust Systems Architecture**: Zero Node.js, npm, Python, Electron, or external daemon runners.
|
||||||
|
- **Authoritative SQLite State**: Fully migration-driven schema with WAL mode, foreign key integrity, and isolated repository operations.
|
||||||
|
- **Single Administrator Security Model**: Strictly 1 administrator identity (`CHECK (id = 1)`), Argon2id password hashing, SHA-256 API token authentication, and sliding-window brute force lockout.
|
||||||
|
- **Native Linux WireGuard Engine**: Direct interaction with Linux networking and kernel interfaces without shelling out to `wg` or `wg-quick`.
|
||||||
|
- **nftables Isolation**: Dedicated `table inet nx9_wg` with input, forward, and NAT postrouting masquerade chains.
|
||||||
|
- **Continuous Reconciliation**: Automated drift detection and idempotent convergence between desired database state and live Linux kernel state.
|
||||||
|
- **Pure Rust Client Enrollment**: Full-tunnel and split-tunnel `.conf` builder, high-resolution SVG/PNG QR generator, and ASCII terminal QR output.
|
||||||
|
- **Consistent Backups**: Atomic SQLite snapshots (`VACUUM INTO`), manifest hashing with SHA-256, verification, and safety snapshots before restore.
|
||||||
|
- **Complete CLI & Axum REST API**: Multi-format CLI (`table`, `json`, `yaml`, `csv`) and RESTful API with real-time WebSocket telemetry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Build and Run Tests
|
||||||
|
```bash
|
||||||
|
# Build the workspace
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Run all 41 unit and integration tests
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Initialize the Administrator
|
||||||
|
```bash
|
||||||
|
# Initialize with a generated password:
|
||||||
|
cargo run -- init --generate-password
|
||||||
|
|
||||||
|
# Or initialize with a specific password:
|
||||||
|
cargo run -- init --username admin --password "YourStrongPassword123!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Daemon
|
||||||
|
```bash
|
||||||
|
cargo run -- serve --bind 0.0.0.0:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create an Interface and Enroll a Peer via CLI
|
||||||
|
```bash
|
||||||
|
# Create WireGuard interface wg0
|
||||||
|
cargo run -- interface create --name wg0 --port 51820 --address-v4 10.0.0.1/24
|
||||||
|
|
||||||
|
# Create peer Alice
|
||||||
|
cargo run -- peer create --interface-id <INTERFACE_UUID> --name alice --address-v4 10.0.0.2/32
|
||||||
|
|
||||||
|
# Display terminal QR code for instant mobile scan:
|
||||||
|
cargo run -- peer qr <PEER_UUID>
|
||||||
|
|
||||||
|
# Print client .conf file:
|
||||||
|
cargo run -- peer config <PEER_UUID>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ nx9-wg CLI │
|
||||||
|
└───────────────────────────┬────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼────────────────────────────┐
|
||||||
|
│ Axum REST API & WebSockets │
|
||||||
|
└───────┬───────────────────┬───────────────────┬────────┘
|
||||||
|
│ │ │
|
||||||
|
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
|
||||||
|
│ nx9-db │ │ nx9-wireguard │ │ nx9-network │
|
||||||
|
│ (SQLite+WAL) │ │ (Kernel WG) │ │(Routes+nftables)│
|
||||||
|
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
|
||||||
|
│ │ │
|
||||||
|
└───────────────────┼───────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────▼───────────────┐
|
||||||
|
│ Reconciliation Engine │
|
||||||
|
│ (Desired vs Live Kernel) │
|
||||||
|
└───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
For complete architectural details, see [Architecture Documentation](docs/architecture.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
- [Architecture & Crate Design](docs/architecture.md)
|
||||||
|
- [Installation & Systemd Setup](docs/installation.md)
|
||||||
|
- [Configuration Reference](docs/configuration.md)
|
||||||
|
- [CLI Command Guide](docs/cli.md)
|
||||||
|
- [REST API & WebSocket Reference](docs/api.md)
|
||||||
|
- [Security Model & Auditing](docs/security.md)
|
||||||
|
- [Docker & Container Deployment](docs/docker.md)
|
||||||
|
- [Backup & Restore Procedures](docs/backup_restore.md)
|
||||||
|
- [Development & Testing Guide](docs/development.md)
|
||||||
|
- [Linux Kernel Requirements](docs/linux_requirements.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright (c) NX9 Systems. All rights reserved.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# NX9 WireGuard - Configuration File Example
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Directory where persistent database and state files are stored
|
||||||
|
data_dir = "/var/lib/nx9-wg"
|
||||||
|
|
||||||
|
# Bind address and port for the Axum REST API and WebSocket daemon
|
||||||
|
bind_address = "127.0.0.1:8080"
|
||||||
|
|
||||||
|
# Application log level (trace, debug, info, warn, error)
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
# Inactivity expiration for administrator web sessions in hours
|
||||||
|
session_expiry_hours = 24
|
||||||
|
|
||||||
|
# Interval in seconds between background kernel reconciliation cycles
|
||||||
|
reconciliation_interval_secs = 60
|
||||||
|
|
||||||
|
# ── Backup Configuration ──────────────────────────────────────────────────────
|
||||||
|
[backup]
|
||||||
|
# Directory where backup snapshots and manifests are generated
|
||||||
|
dir = "/var/lib/nx9-wg/backups"
|
||||||
|
|
||||||
|
# Maximum number of automated backup snapshots to retain
|
||||||
|
max_count = 10
|
||||||
|
|
||||||
|
# Optional cron schedule for automated database backups (e.g. "0 2 * * *" for 02:00 UTC)
|
||||||
|
# schedule = "0 2 * * *"
|
||||||
|
|
||||||
|
# ── Initial Bootstrap Configuration (Optional) ──────────────────────────────
|
||||||
|
# Used only during initial startup when no administrator exists in the database.
|
||||||
|
# Ignored once the single administrator identity is provisioned in SQLite.
|
||||||
|
# [bootstrap]
|
||||||
|
# admin_username = "admin"
|
||||||
|
# admin_password = "ChangeMeToASecurePassword123!"
|
||||||
|
|
||||||
|
# ── Static Admin Definition (Optional Override) ───────────────────────────────
|
||||||
|
# [admin]
|
||||||
|
# username = "admin"
|
||||||
|
# password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "nx9-wg-api"
|
||||||
|
description = "Axum REST API, application services, and WebSocket server for nx9-wg"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nx9-wg-core.workspace = true
|
||||||
|
nx9-wg-db.workspace = true
|
||||||
|
nx9-wireguard.workspace = true
|
||||||
|
nx9-wg-network.workspace = true
|
||||||
|
nx9-wg-ui.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
ipnet.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//! Deterministic, collision-free automatic IP address allocation service.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::types::network::Network;
|
||||||
|
use nx9_wg_core::types::wireguard::Interface;
|
||||||
|
use nx9_wg_core::validation::validate_ip_in_network;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::net::{IpAddr, Ipv6Addr};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Details of an allocated IP address within a network.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NetworkAllocation {
|
||||||
|
pub ip_address: String,
|
||||||
|
pub peer_id: Option<Uuid>,
|
||||||
|
pub peer_name: Option<String>,
|
||||||
|
pub peer_state: Option<String>,
|
||||||
|
pub allocated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core IP address allocator service.
|
||||||
|
pub struct IpAllocator;
|
||||||
|
|
||||||
|
impl IpAllocator {
|
||||||
|
/// Deterministically allocate the next available IP in a network CIDR.
|
||||||
|
/// Excludes:
|
||||||
|
/// - Network address
|
||||||
|
/// - Broadcast address (for IPv4)
|
||||||
|
/// - Interface address
|
||||||
|
/// - Existing peer allocations (active, disabled, expired)
|
||||||
|
pub async fn allocate_next_ip(
|
||||||
|
store: &Store,
|
||||||
|
network: &Network,
|
||||||
|
interface: Option<&Interface>,
|
||||||
|
preferred_ip: Option<IpAddr>,
|
||||||
|
) -> ApiResult<IpNet> {
|
||||||
|
let net = network.cidr;
|
||||||
|
|
||||||
|
// If preferred IP is supplied, validate and ensure no collision
|
||||||
|
if let Some(pref) = preferred_ip {
|
||||||
|
validate_ip_in_network(pref, net).map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let exclusions = Self::get_exclusions(store, network, interface).await?;
|
||||||
|
if exclusions.contains(&pref) {
|
||||||
|
return Err(ApiError::Conflict(format!(
|
||||||
|
"Requested IP '{pref}' is already allocated or reserved in network '{}'",
|
||||||
|
network.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cidr_suffix = match net {
|
||||||
|
IpNet::V4(_) => 32,
|
||||||
|
IpNet::V6(_) => 128,
|
||||||
|
};
|
||||||
|
return IpNet::new(pref, cidr_suffix).map_err(|e| ApiError::Validation(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let exclusions = Self::get_exclusions(store, network, interface).await?;
|
||||||
|
|
||||||
|
match net {
|
||||||
|
IpNet::V4(v4_net) => {
|
||||||
|
for host in v4_net.hosts() {
|
||||||
|
let ip = IpAddr::V4(host);
|
||||||
|
if !exclusions.contains(&ip) {
|
||||||
|
return Ok(IpNet::V4(
|
||||||
|
ipnet::Ipv4Net::new(host, 32)
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ApiError::Conflict(format!(
|
||||||
|
"IPv4 network '{}' ({}) is completely exhausted",
|
||||||
|
network.name, network.cidr
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
IpNet::V6(v6_net) => {
|
||||||
|
let start_u128 = u128::from(v6_net.network());
|
||||||
|
// Search up to 65536 host addresses deterministically
|
||||||
|
for offset in 2u128..65536u128 {
|
||||||
|
let candidate_u128 = start_u128 + offset;
|
||||||
|
let candidate = Ipv6Addr::from(candidate_u128);
|
||||||
|
let ip = IpAddr::V6(candidate);
|
||||||
|
if v6_net.contains(&candidate) && !exclusions.contains(&ip) {
|
||||||
|
return Ok(IpNet::V6(
|
||||||
|
ipnet::Ipv6Net::new(candidate, 128)
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ApiError::Conflict(format!(
|
||||||
|
"IPv6 network '{}' ({}) allocation window exhausted",
|
||||||
|
network.name, network.cidr
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List next N available unallocated IP addresses in the network.
|
||||||
|
pub async fn list_available_ips(
|
||||||
|
store: &Store,
|
||||||
|
network: &Network,
|
||||||
|
interface: Option<&Interface>,
|
||||||
|
limit: usize,
|
||||||
|
) -> ApiResult<Vec<IpAddr>> {
|
||||||
|
let net = network.cidr;
|
||||||
|
let exclusions = Self::get_exclusions(store, network, interface).await?;
|
||||||
|
let mut available = Vec::new();
|
||||||
|
|
||||||
|
match net {
|
||||||
|
IpNet::V4(v4_net) => {
|
||||||
|
for host in v4_net.hosts() {
|
||||||
|
let ip = IpAddr::V4(host);
|
||||||
|
if !exclusions.contains(&ip) {
|
||||||
|
available.push(ip);
|
||||||
|
if available.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IpNet::V6(v6_net) => {
|
||||||
|
let start_u128 = u128::from(v6_net.network());
|
||||||
|
for offset in 2u128..65536u128 {
|
||||||
|
let candidate_u128 = start_u128 + offset;
|
||||||
|
let candidate = Ipv6Addr::from(candidate_u128);
|
||||||
|
let ip = IpAddr::V6(candidate);
|
||||||
|
if v6_net.contains(&candidate) && !exclusions.contains(&ip) {
|
||||||
|
available.push(ip);
|
||||||
|
if available.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(available)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List allocated IP addresses in a network.
|
||||||
|
pub async fn list_allocations(
|
||||||
|
store: &Store,
|
||||||
|
network: &Network,
|
||||||
|
) -> Result<Vec<NetworkAllocation>, ApiError> {
|
||||||
|
let all_peers = store.list_all_peers().await?;
|
||||||
|
let net = network.cidr;
|
||||||
|
let mut allocations = Vec::new();
|
||||||
|
|
||||||
|
for peer in all_peers {
|
||||||
|
if let Some(v4) = peer.address_v4.filter(|addr| net.contains(&addr.addr())) {
|
||||||
|
allocations.push(NetworkAllocation {
|
||||||
|
ip_address: v4.to_string(),
|
||||||
|
peer_id: Some(peer.id),
|
||||||
|
peer_name: Some(peer.name.clone()),
|
||||||
|
peer_state: Some(peer.state.to_string()),
|
||||||
|
allocated_at: peer.created_at.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(v6) = peer.address_v6.filter(|addr| net.contains(&addr.addr())) {
|
||||||
|
allocations.push(NetworkAllocation {
|
||||||
|
ip_address: v6.to_string(),
|
||||||
|
peer_id: Some(peer.id),
|
||||||
|
peer_name: Some(peer.name.clone()),
|
||||||
|
peer_state: Some(peer.state.to_string()),
|
||||||
|
allocated_at: peer.created_at.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(allocations)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to compute reserved and allocated exclusion IP set.
|
||||||
|
async fn get_exclusions(
|
||||||
|
store: &Store,
|
||||||
|
network: &Network,
|
||||||
|
interface: Option<&Interface>,
|
||||||
|
) -> ApiResult<HashSet<IpAddr>> {
|
||||||
|
let mut exclusions = HashSet::new();
|
||||||
|
let net = network.cidr;
|
||||||
|
|
||||||
|
// Exclude network and broadcast
|
||||||
|
match net {
|
||||||
|
IpNet::V4(v4) => {
|
||||||
|
exclusions.insert(IpAddr::V4(v4.network()));
|
||||||
|
exclusions.insert(IpAddr::V4(v4.broadcast()));
|
||||||
|
}
|
||||||
|
IpNet::V6(v6) => {
|
||||||
|
exclusions.insert(IpAddr::V6(v6.network()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude interface addresses
|
||||||
|
if let Some(iface) = interface {
|
||||||
|
exclusions.insert(iface.address_v4.addr());
|
||||||
|
if let Some(v6) = iface.address_v6 {
|
||||||
|
exclusions.insert(v6.addr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude existing peer allocations
|
||||||
|
let allocated_strs = store.get_allocated_ips(interface.map(|i| i.id)).await?;
|
||||||
|
for s in allocated_strs {
|
||||||
|
if let Ok(ipnet) = s.parse::<IpNet>() {
|
||||||
|
exclusions.insert(ipnet.addr());
|
||||||
|
} else if let Ok(ip) = s.parse::<IpAddr>() {
|
||||||
|
exclusions.insert(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(exclusions)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
//! Administrator bootstrap resolution and initial provisioning.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use nx9_wg_core::config::AppConfig;
|
||||||
|
use nx9_wg_core::crypto::{generate_secure_password, hash_password};
|
||||||
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
|
use nx9_wg_core::types::auth::Admin;
|
||||||
|
use nx9_wg_core::validation::validate_password_strength;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Options supplied to the administrator bootstrap resolver.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct BootstrapOptions {
|
||||||
|
/// Administrator username (default: "admin")
|
||||||
|
pub admin_username: Option<String>,
|
||||||
|
|
||||||
|
/// Explicit CLI argument password
|
||||||
|
pub cli_password: Option<String>,
|
||||||
|
|
||||||
|
/// Password file path (e.g. Docker secrets / NX9_WG_ADMIN_PASSWORD_FILE)
|
||||||
|
pub password_file: Option<String>,
|
||||||
|
|
||||||
|
/// Raw password read from stdin
|
||||||
|
pub stdin_password: Option<String>,
|
||||||
|
|
||||||
|
/// Request to generate a cryptographically secure random password
|
||||||
|
pub generate_password: bool,
|
||||||
|
|
||||||
|
/// Optional path to write generated password (chmod 0600)
|
||||||
|
pub write_password_file: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Description of which credential source was resolved during bootstrap.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResolvedSource {
|
||||||
|
CliArgument,
|
||||||
|
Stdin,
|
||||||
|
PasswordFile,
|
||||||
|
EnvironmentVariable,
|
||||||
|
ConfigFile,
|
||||||
|
Generated,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedSource {
|
||||||
|
pub fn description(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::CliArgument => "explicit CLI argument",
|
||||||
|
Self::Stdin => "standard input (--password-stdin)",
|
||||||
|
Self::PasswordFile => "password file / secret",
|
||||||
|
Self::EnvironmentVariable => "environment variable (NX9_WG_ADMIN_PASSWORD)",
|
||||||
|
Self::ConfigFile => "configuration file ([bootstrap])",
|
||||||
|
Self::Generated => "secure random generation",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful administrator bootstrap operation.
|
||||||
|
pub struct BootstrapResult {
|
||||||
|
pub admin: Admin,
|
||||||
|
pub source: ResolvedSource,
|
||||||
|
pub generated_plaintext: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve administrator credentials following documented precedence rules and initialize SQLite.
|
||||||
|
pub async fn bootstrap_admin(
|
||||||
|
store: &Store,
|
||||||
|
config: &AppConfig,
|
||||||
|
opts: &BootstrapOptions,
|
||||||
|
) -> ApiResult<BootstrapResult> {
|
||||||
|
// 1. Check if admin already exists
|
||||||
|
if store.admin_exists().await? {
|
||||||
|
return Err(ApiError::Conflict(
|
||||||
|
"Administrator has already been initialized. Use 'nx9-wg admin password' to reset."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve username
|
||||||
|
let username = opts
|
||||||
|
.admin_username
|
||||||
|
.clone()
|
||||||
|
.or_else(|| std::env::var("NX9_WG_ADMIN_USERNAME").ok())
|
||||||
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.bootstrap
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.admin_username.clone())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "admin".to_string());
|
||||||
|
|
||||||
|
// 3. Resolve password following strict precedence:
|
||||||
|
// 1: Explicit CLI argument
|
||||||
|
// 2: Stdin
|
||||||
|
// 3: Password file / NX9_WG_ADMIN_PASSWORD_FILE
|
||||||
|
// 4: NX9_WG_ADMIN_PASSWORD
|
||||||
|
// 5: Config file bootstrap credential
|
||||||
|
// 6: Generated password
|
||||||
|
let (password, source, generated_plaintext) = if let Some(ref pw) = opts.cli_password {
|
||||||
|
tracing::warn!(
|
||||||
|
"Administrator password was supplied via CLI argument. Note that shell history and process lists may expose it. Prefer --admin-password-stdin or secrets files in production."
|
||||||
|
);
|
||||||
|
(pw.clone(), ResolvedSource::CliArgument, None)
|
||||||
|
} else if let Some(ref pw) = opts.stdin_password {
|
||||||
|
(pw.trim().to_string(), ResolvedSource::Stdin, None)
|
||||||
|
} else if let Some(ref file_path) = opts
|
||||||
|
.password_file
|
||||||
|
.clone()
|
||||||
|
.or_else(|| std::env::var("NX9_WG_ADMIN_PASSWORD_FILE").ok())
|
||||||
|
{
|
||||||
|
let content = std::fs::read_to_string(file_path).map_err(|e| {
|
||||||
|
ApiError::BadRequest(format!("Failed to read password file '{file_path}': {e}"))
|
||||||
|
})?;
|
||||||
|
(
|
||||||
|
content.trim().to_string(),
|
||||||
|
ResolvedSource::PasswordFile,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
} else if let Ok(env_pw) = std::env::var("NX9_WG_ADMIN_PASSWORD") {
|
||||||
|
(env_pw, ResolvedSource::EnvironmentVariable, None)
|
||||||
|
} else if let Some(ref boot) = config.bootstrap {
|
||||||
|
if let Some(ref pw) = boot.admin_password {
|
||||||
|
(pw.clone(), ResolvedSource::ConfigFile, None)
|
||||||
|
} else if opts.generate_password {
|
||||||
|
let generated_pw = generate_secure_password(24);
|
||||||
|
(
|
||||||
|
generated_pw.clone(),
|
||||||
|
ResolvedSource::Generated,
|
||||||
|
Some(generated_pw),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"No administrator password provided. Use 'nx9-wg init' or configure credentials."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if opts.generate_password {
|
||||||
|
let generated_pw = generate_secure_password(24);
|
||||||
|
(
|
||||||
|
generated_pw.clone(),
|
||||||
|
ResolvedSource::Generated,
|
||||||
|
Some(generated_pw),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"No administrator password provided. Use 'nx9-wg init' or configure credentials."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Validate password strength
|
||||||
|
validate_password_strength(&password)?;
|
||||||
|
|
||||||
|
// 5. Convert to Argon2id hash immediately
|
||||||
|
let hash = hash_password(&password)?;
|
||||||
|
|
||||||
|
// 6. If password was generated and a file output was requested, write with 0600 permissions
|
||||||
|
if let (Some(generated_pw), Some(path_str)) = (&generated_plaintext, &opts.write_password_file)
|
||||||
|
{
|
||||||
|
let path = Path::new(path_str);
|
||||||
|
if let Some(parent) = path
|
||||||
|
.parent()
|
||||||
|
.filter(|p| !p.as_os_str().is_empty() && !p.exists())
|
||||||
|
{
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to create password file directory: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, generated_pw)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to write password file: {e}")))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Persist admin in SQLite (hash only, id=1)
|
||||||
|
let admin = store.create_admin(&username, &hash).await?;
|
||||||
|
|
||||||
|
// 8. Record audit event
|
||||||
|
let _ = store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::AdminInitialized,
|
||||||
|
&username,
|
||||||
|
Some("admin"),
|
||||||
|
Some("1"),
|
||||||
|
Some(&format!(
|
||||||
|
"Administrator initialized via {}",
|
||||||
|
source.description()
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
username = %username,
|
||||||
|
source = %source.description(),
|
||||||
|
"Administrator initialized successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(BootstrapResult {
|
||||||
|
admin,
|
||||||
|
source,
|
||||||
|
generated_plaintext,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! Authentication middleware for Axum endpoints.
|
||||||
|
|
||||||
|
use crate::auth::service::AuthService;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use axum::extract::Request;
|
||||||
|
use axum::http::header::{AUTHORIZATION, COOKIE};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::Response;
|
||||||
|
|
||||||
|
/// Authenticated identity attached to request extensions.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthenticatedAdmin {
|
||||||
|
pub username: String,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub token_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require authentication middleware: validates either a session cookie or a Bearer token.
|
||||||
|
pub async fn require_auth(
|
||||||
|
axum::extract::State(auth): axum::extract::State<AuthService>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
// 1. Try Bearer token in Authorization header
|
||||||
|
if let Some(token) = req
|
||||||
|
.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_") {
|
||||||
|
// API token
|
||||||
|
let api_token = auth.authenticate_token(token).await?;
|
||||||
|
req.extensions_mut().insert(AuthenticatedAdmin {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
token_id: Some(api_token.id),
|
||||||
|
});
|
||||||
|
return Ok(next.run(req).await);
|
||||||
|
} else {
|
||||||
|
// Session ID in Bearer header
|
||||||
|
let session = auth.authenticate_session(token).await?;
|
||||||
|
req.extensions_mut().insert(AuthenticatedAdmin {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
session_id: Some(session.id),
|
||||||
|
token_id: None,
|
||||||
|
});
|
||||||
|
return Ok(next.run(req).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try session cookie (nx9_session=...)
|
||||||
|
if 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=") {
|
||||||
|
let session_id = session_id.trim();
|
||||||
|
let session = auth.authenticate_session(session_id).await?;
|
||||||
|
req.extensions_mut().insert(AuthenticatedAdmin {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
session_id: Some(session.id),
|
||||||
|
token_id: None,
|
||||||
|
});
|
||||||
|
return Ok(next.run(req).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ApiError::Unauthenticated(
|
||||||
|
"Authentication required. Provide a valid session cookie or Bearer token.".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//! Authentication and security subsystem.
|
||||||
|
|
||||||
|
pub mod bootstrap;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use bootstrap::{BootstrapOptions, BootstrapResult, ResolvedSource, bootstrap_admin};
|
||||||
|
pub use middleware::{AuthenticatedAdmin, require_auth};
|
||||||
|
pub use service::AuthService;
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
//! Authentication service handling login, session validation, API tokens, and password rotation.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use chrono::{Duration, NaiveDateTime, Utc};
|
||||||
|
use nx9_wg_core::crypto::{
|
||||||
|
generate_api_token, generate_session_id, hash_password, verify_password,
|
||||||
|
};
|
||||||
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
|
use nx9_wg_core::types::auth::{ApiToken, Session};
|
||||||
|
use nx9_wg_core::validation::validate_password_strength;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Maximum failed login attempts allowed within the sliding window.
|
||||||
|
pub const MAX_FAILED_ATTEMPTS: i64 = 5;
|
||||||
|
|
||||||
|
/// Sliding window duration in minutes for login rate limiting.
|
||||||
|
pub const RATE_LIMIT_WINDOW_MINUTES: i64 = 15;
|
||||||
|
|
||||||
|
/// Default session validity duration.
|
||||||
|
pub const DEFAULT_SESSION_DURATION_HOURS: i64 = 24;
|
||||||
|
|
||||||
|
/// Authentication service wrapping database operations and cryptographic validation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthService {
|
||||||
|
store: Store,
|
||||||
|
session_duration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
/// Create a new AuthService instance.
|
||||||
|
pub fn new(store: Store) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
session_duration: Duration::hours(DEFAULT_SESSION_DURATION_HOURS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an AuthService with custom session expiry duration.
|
||||||
|
pub fn with_session_duration(store: Store, session_duration: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
session_duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get underlying database store handle.
|
||||||
|
pub fn store(&self) -> &Store {
|
||||||
|
&self.store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate administrator with username and password, rate-limiting on failure.
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
) -> ApiResult<Session> {
|
||||||
|
let client_ip = ip_address.unwrap_or("127.0.0.1");
|
||||||
|
|
||||||
|
// 1. Check rate limit
|
||||||
|
let failed_count = self
|
||||||
|
.store
|
||||||
|
.count_recent_failed_attempts(client_ip, RATE_LIMIT_WINDOW_MINUTES)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if failed_count >= MAX_FAILED_ATTEMPTS {
|
||||||
|
let msg = format!(
|
||||||
|
"Too many failed login attempts from IP {client_ip}. Please wait {RATE_LIMIT_WINDOW_MINUTES} minutes before retrying."
|
||||||
|
);
|
||||||
|
tracing::warn!(ip = %client_ip, "Login rate-limit lockout triggered");
|
||||||
|
return Err(ApiError::RateLimited(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch admin
|
||||||
|
let admin = match self.store.get_admin().await? {
|
||||||
|
Some(a) => a,
|
||||||
|
None => {
|
||||||
|
let _ = self.store.record_login_attempt(client_ip, false).await;
|
||||||
|
return Err(ApiError::Unauthenticated(
|
||||||
|
"Administrator not initialized. Please run bootstrap first.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Verify username
|
||||||
|
if admin.username != username {
|
||||||
|
let _ = self.store.record_login_attempt(client_ip, false).await;
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::LoginFailed,
|
||||||
|
username,
|
||||||
|
Some("auth"),
|
||||||
|
None,
|
||||||
|
Some("Invalid username during login attempt"),
|
||||||
|
None,
|
||||||
|
Some(client_ip),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(ApiError::Unauthenticated(
|
||||||
|
"Invalid administrator username or password".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verify password with Argon2id
|
||||||
|
let valid = verify_password(password, &admin.password_hash)?;
|
||||||
|
if !valid {
|
||||||
|
let _ = self.store.record_login_attempt(client_ip, false).await;
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::LoginFailed,
|
||||||
|
username,
|
||||||
|
Some("auth"),
|
||||||
|
None,
|
||||||
|
Some("Invalid password during login attempt"),
|
||||||
|
None,
|
||||||
|
Some(client_ip),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(ApiError::Unauthenticated(
|
||||||
|
"Invalid administrator username or password".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Successful login: clear failed attempts and record login details
|
||||||
|
let _ = self.store.record_login_attempt(client_ip, true).await;
|
||||||
|
let _ = self.store.clear_login_attempts(client_ip).await;
|
||||||
|
let _ = self.store.record_admin_login(Some(client_ip)).await;
|
||||||
|
|
||||||
|
// 6. Create session
|
||||||
|
let session_id = generate_session_id();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let session = Session {
|
||||||
|
id: session_id,
|
||||||
|
admin_id: 1,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: now + self.session_duration,
|
||||||
|
last_seen_at: Some(now),
|
||||||
|
ip_address: ip_address.map(|s| s.to_string()),
|
||||||
|
user_agent: user_agent.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.store.create_session(&session).await?;
|
||||||
|
|
||||||
|
// 7. Audit log
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::Login,
|
||||||
|
username,
|
||||||
|
Some("session"),
|
||||||
|
Some(&session.id),
|
||||||
|
Some("Administrator login successful"),
|
||||||
|
None,
|
||||||
|
Some(client_ip),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate an incoming request by session ID.
|
||||||
|
pub async fn authenticate_session(&self, session_id: &str) -> ApiResult<Session> {
|
||||||
|
let session = match self.store.get_session(session_id).await? {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Err(ApiError::Unauthenticated("Session not found".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
if session.expires_at < now {
|
||||||
|
// Delete expired session
|
||||||
|
let _ = self.store.delete_session(session_id).await;
|
||||||
|
return Err(ApiError::Unauthenticated("Session has expired".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch session
|
||||||
|
let _ = self.store.touch_session(session_id).await;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate an incoming request by raw API token.
|
||||||
|
pub async fn authenticate_token(&self, raw_token: &str) -> ApiResult<ApiToken> {
|
||||||
|
let hash_bytes = Sha256::digest(raw_token.as_bytes());
|
||||||
|
let token_hash = hash_bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
let token = match self.store.find_token_by_hash(&token_hash).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Err(ApiError::Unauthenticated("Invalid API token".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
if token.revoked {
|
||||||
|
return Err(ApiError::Unauthenticated(
|
||||||
|
"API token has been revoked".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
if token.expires_at.is_some_and(|exp| exp < now) {
|
||||||
|
return Err(ApiError::Unauthenticated(
|
||||||
|
"API token has expired".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark token used
|
||||||
|
let _ = self.store.mark_token_used(&token.id).await;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change administrator password and invalidate all active sessions.
|
||||||
|
pub async fn change_password(
|
||||||
|
&self,
|
||||||
|
new_password: &str,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
validate_password_strength(new_password)?;
|
||||||
|
|
||||||
|
let hash = hash_password(new_password)?;
|
||||||
|
self.store.update_admin_password(&hash).await?;
|
||||||
|
|
||||||
|
// Invalidate all existing sessions globally
|
||||||
|
let invalidated = self.store.delete_all_admin_sessions(1).await?;
|
||||||
|
|
||||||
|
// Audit password change and session invalidation
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::PasswordChange,
|
||||||
|
"admin",
|
||||||
|
Some("admin"),
|
||||||
|
Some("1"),
|
||||||
|
Some("Administrator password changed successfully"),
|
||||||
|
None,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::SessionInvalidated,
|
||||||
|
"admin",
|
||||||
|
Some("session"),
|
||||||
|
None,
|
||||||
|
Some(&format!(
|
||||||
|
"Invalidated {invalidated} sessions after password change"
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
invalidated_sessions = invalidated,
|
||||||
|
"Administrator password changed; all active sessions invalidated"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout and invalidate a specific session.
|
||||||
|
pub async fn logout(&self, session_id: &str, ip_address: Option<&str>) -> ApiResult<()> {
|
||||||
|
self.store.delete_session(session_id).await?;
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::Logout,
|
||||||
|
"admin",
|
||||||
|
Some("session"),
|
||||||
|
Some(session_id),
|
||||||
|
Some("Administrator logged out"),
|
||||||
|
None,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new API token. Returns the stored ApiToken metadata and the raw plaintext token (shown only once).
|
||||||
|
pub async fn create_api_token(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
expires_at: Option<NaiveDateTime>,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
) -> ApiResult<(ApiToken, String)> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(ApiError::Validation(
|
||||||
|
"API token name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (raw_token, token_hash) = generate_api_token();
|
||||||
|
let token_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let token = ApiToken {
|
||||||
|
id: token_id.clone(),
|
||||||
|
admin_id: 1,
|
||||||
|
name: name.to_string(),
|
||||||
|
token_hash,
|
||||||
|
created_at: now,
|
||||||
|
expires_at,
|
||||||
|
last_used_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.store.create_token(&token).await?;
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::ApiTokenCreate,
|
||||||
|
"admin",
|
||||||
|
Some("api_token"),
|
||||||
|
Some(&token_id),
|
||||||
|
Some(&format!("API token '{name}' created")),
|
||||||
|
None,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok((token, raw_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke an API token by ID.
|
||||||
|
pub async fn revoke_api_token(&self, id: &str, ip_address: Option<&str>) -> ApiResult<()> {
|
||||||
|
self.store.revoke_token(id).await?;
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::ApiTokenRevoke,
|
||||||
|
"admin",
|
||||||
|
Some("api_token"),
|
||||||
|
Some(id),
|
||||||
|
Some("API token revoked"),
|
||||||
|
None,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//! Backup and Restore engine for consistent SQLite snapshots and manifests.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
|
use nx9_wg_core::types::backup::{BackupFileEntry, BackupManifest, BackupMeta};
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Backup and restore management service.
|
||||||
|
pub struct BackupService;
|
||||||
|
|
||||||
|
impl BackupService {
|
||||||
|
/// Create a consistent, atomic SQLite snapshot backup and manifest.
|
||||||
|
pub async fn create_backup(
|
||||||
|
store: &Store,
|
||||||
|
backup_dir: &Path,
|
||||||
|
description: Option<&str>,
|
||||||
|
actor: &str,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
) -> ApiResult<(BackupMeta, PathBuf)> {
|
||||||
|
if !backup_dir.exists() {
|
||||||
|
std::fs::create_dir_all(backup_dir).map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to create backup directory: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||||
|
let filename = format!("nx9-backup-{timestamp}.db");
|
||||||
|
let backup_path = backup_dir.join(&filename);
|
||||||
|
let backup_path_str = backup_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 1. Perform atomic SQLite VACUUM INTO
|
||||||
|
store.vacuum_into(&backup_path_str).await?;
|
||||||
|
|
||||||
|
// 2. Read bytes to compute checksum and size
|
||||||
|
let bytes = std::fs::read(&backup_path)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to read created backup file: {e}")))?;
|
||||||
|
let size_bytes = bytes.len() as i64;
|
||||||
|
let hash_bytes = Sha256::digest(&bytes);
|
||||||
|
let checksum = hash_bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let backup_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let meta = BackupMeta {
|
||||||
|
id: backup_id,
|
||||||
|
filename: filename.clone(),
|
||||||
|
size_bytes,
|
||||||
|
checksum: checksum.clone(),
|
||||||
|
schema_version: "1".to_string(),
|
||||||
|
encrypted: false,
|
||||||
|
description: description.map(|s| s.to_string()),
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Write manifest file
|
||||||
|
let manifest = BackupManifest {
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
schema_version: "1".to_string(),
|
||||||
|
created_at: now,
|
||||||
|
checksum: checksum.clone(),
|
||||||
|
encrypted: false,
|
||||||
|
files: vec![BackupFileEntry {
|
||||||
|
path: filename.clone(),
|
||||||
|
size_bytes: size_bytes as u64,
|
||||||
|
checksum: checksum.clone(),
|
||||||
|
}],
|
||||||
|
notes: description.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let manifest_path = backup_dir.join(format!("nx9-backup-{timestamp}.manifest.json"));
|
||||||
|
let manifest_json = serde_json::to_string_pretty(&manifest)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to serialize backup manifest: {e}")))?;
|
||||||
|
std::fs::write(&manifest_path, manifest_json)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to write backup manifest: {e}")))?;
|
||||||
|
|
||||||
|
// 4. Save metadata in SQLite
|
||||||
|
store.create_backup_meta(&meta).await?;
|
||||||
|
|
||||||
|
// 5. Audit event
|
||||||
|
let _ = store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::BackupCreate,
|
||||||
|
actor,
|
||||||
|
Some("backup"),
|
||||||
|
Some(&backup_id.to_string()),
|
||||||
|
Some(&format!("Created backup '{filename}' ({size_bytes} bytes)")),
|
||||||
|
None,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok((meta, backup_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the integrity and SQLite magic header of a backup file.
|
||||||
|
pub fn verify_backup(backup_file: &Path, expected_checksum: Option<&str>) -> ApiResult<bool> {
|
||||||
|
if !backup_file.exists() {
|
||||||
|
return Err(ApiError::NotFound(format!(
|
||||||
|
"Backup file '{}' not found",
|
||||||
|
backup_file.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::fs::read(backup_file).map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to read backup file for verification: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if bytes.len() < 100 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SQLite 3 header magic
|
||||||
|
if &bytes[0..16] != b"SQLite format 3\0" {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum if supplied
|
||||||
|
if let Some(expected) = expected_checksum {
|
||||||
|
let hash_bytes = Sha256::digest(&bytes);
|
||||||
|
let calculated = hash_bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
if calculated.to_lowercase() != expected.to_lowercase() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore database from a verified backup file with safety pre-restore backup snapshot.
|
||||||
|
pub async fn restore_backup(
|
||||||
|
store: &Store,
|
||||||
|
backup_file: &Path,
|
||||||
|
active_db_path: &Path,
|
||||||
|
safety_dir: &Path,
|
||||||
|
actor: &str,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
// 1. Verify backup file before touching active DB
|
||||||
|
let is_valid = Self::verify_backup(backup_file, None)?;
|
||||||
|
if !is_valid {
|
||||||
|
return Err(ApiError::Validation(
|
||||||
|
"Backup file failed verification: invalid SQLite format or corrupted data"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create pre-restore safety snapshot of the active database
|
||||||
|
if active_db_path.exists() {
|
||||||
|
if !safety_dir.exists() {
|
||||||
|
let _ = std::fs::create_dir_all(safety_dir);
|
||||||
|
}
|
||||||
|
let safety_name = format!(
|
||||||
|
"pre-restore-safety-{}.bak",
|
||||||
|
Utc::now().format("%Y%m%d-%H%M%S")
|
||||||
|
);
|
||||||
|
let safety_path = safety_dir.join(safety_name);
|
||||||
|
let _ = store.vacuum_into(&safety_path.to_string_lossy()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Record audit event before closing pool
|
||||||
|
let _ = store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::BackupRestore,
|
||||||
|
actor,
|
||||||
|
Some("backup"),
|
||||||
|
None,
|
||||||
|
Some(&format!(
|
||||||
|
"Database restore initiated from '{}'",
|
||||||
|
backup_file.display()
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. Close store pool to release file locks
|
||||||
|
store.close().await;
|
||||||
|
|
||||||
|
// 5. Clean up existing active database and WAL, SHM, and journal files
|
||||||
|
let wal_path = PathBuf::from(format!("{}-wal", active_db_path.display()));
|
||||||
|
let shm_path = PathBuf::from(format!("{}-shm", active_db_path.display()));
|
||||||
|
let journal_path = PathBuf::from(format!("{}-journal", active_db_path.display()));
|
||||||
|
let _ = std::fs::remove_file(wal_path);
|
||||||
|
let _ = std::fs::remove_file(shm_path);
|
||||||
|
let _ = std::fs::remove_file(journal_path);
|
||||||
|
let _ = std::fs::remove_file(active_db_path);
|
||||||
|
|
||||||
|
// 6. Copy backup file to active database location
|
||||||
|
std::fs::copy(backup_file, active_db_path)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to restore database file: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
//! Native diagnostics service for WireGuard, Linux networking, kernel sysctl, and reconciliation.
|
||||||
|
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::reconciliation::ReconciliationEngine;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::diagnostics::{
|
||||||
|
DiagnosticCheck, DiagnosticReport, DiagnosticStatus, DiagnosticSubsystem,
|
||||||
|
};
|
||||||
|
use nx9_wg_network::NetworkEngine;
|
||||||
|
use nx9_wireguard::WireGuardEngine;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Native diagnostics inspection service.
|
||||||
|
pub struct DiagnosticsService {
|
||||||
|
state: AppState,
|
||||||
|
wg_engine: Arc<dyn WireGuardEngine>,
|
||||||
|
net_engine: Arc<dyn NetworkEngine>,
|
||||||
|
reconciler: Arc<ReconciliationEngine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiagnosticsService {
|
||||||
|
/// Create a new diagnostics service.
|
||||||
|
pub fn new(
|
||||||
|
state: AppState,
|
||||||
|
wg_engine: Arc<dyn WireGuardEngine>,
|
||||||
|
net_engine: Arc<dyn NetworkEngine>,
|
||||||
|
reconciler: Arc<ReconciliationEngine>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
wg_engine,
|
||||||
|
net_engine,
|
||||||
|
reconciler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run diagnostic check for a target subsystem.
|
||||||
|
pub async fn run_diagnostic(
|
||||||
|
&self,
|
||||||
|
subsystem: DiagnosticSubsystem,
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
) -> ApiResult<Vec<DiagnosticReport>> {
|
||||||
|
match subsystem {
|
||||||
|
DiagnosticSubsystem::System => Ok(vec![self.diagnose_system().await?]),
|
||||||
|
DiagnosticSubsystem::Network => Ok(vec![self.diagnose_network().await?]),
|
||||||
|
DiagnosticSubsystem::Wan => Ok(vec![self.diagnose_wan().await?]),
|
||||||
|
DiagnosticSubsystem::Wireguard => Ok(vec![self.diagnose_wireguard(None).await?]),
|
||||||
|
DiagnosticSubsystem::Peer => {
|
||||||
|
if let Some(id) = peer_id {
|
||||||
|
Ok(vec![self.diagnose_peer(id).await?])
|
||||||
|
} else {
|
||||||
|
let peers = self.state.store.list_all_peers().await?;
|
||||||
|
let mut reports = Vec::new();
|
||||||
|
for p in peers {
|
||||||
|
reports.push(self.diagnose_peer(p.id).await?);
|
||||||
|
}
|
||||||
|
if reports.is_empty() {
|
||||||
|
reports.push(DiagnosticReport {
|
||||||
|
subsystem: "peer".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: DiagnosticStatus::Pass,
|
||||||
|
checks: vec![DiagnosticCheck {
|
||||||
|
check_name: "enrolled_peers".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "0 peers".to_string(),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message:
|
||||||
|
"No peers are currently enrolled in the database".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(reports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DiagnosticSubsystem::Routing => Ok(vec![self.diagnose_routing().await?]),
|
||||||
|
DiagnosticSubsystem::Forwarding => Ok(vec![self.diagnose_forwarding().await?]),
|
||||||
|
DiagnosticSubsystem::Firewall => Ok(vec![self.diagnose_firewall().await?]),
|
||||||
|
DiagnosticSubsystem::Nat => Ok(vec![self.diagnose_nat().await?]),
|
||||||
|
DiagnosticSubsystem::Mtu => Ok(vec![self.diagnose_mtu().await?]),
|
||||||
|
DiagnosticSubsystem::Reconciliation => Ok(vec![self.diagnose_reconciliation().await?]),
|
||||||
|
DiagnosticSubsystem::All => self.diagnose_all().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System subsystem diagnostics.
|
||||||
|
pub async fn diagnose_system(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
|
||||||
|
// Hostname
|
||||||
|
let hostname = std::fs::read_to_string("/etc/hostname")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| "localhost".to_string());
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "hostname".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: hostname,
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "System hostname read successfully".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// OS and Architecture
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "os_architecture".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
|
||||||
|
expected_value: Some("linux-*".to_string()),
|
||||||
|
diagnostic_message: "Supported target platform".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kernel Version
|
||||||
|
let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| "Linux".to_string());
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "kernel_version".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: kernel,
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "Linux kernel release inspected".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memory Info
|
||||||
|
if let Ok(mem) = std::fs::read_to_string("/proc/meminfo") {
|
||||||
|
let mem_total = mem
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("MemTotal:"))
|
||||||
|
.unwrap_or("MemTotal: unknown");
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "memory_status".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: mem_total.to_string(),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "System memory available".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database Health Check
|
||||||
|
let db_health = self.state.store.health_check().await;
|
||||||
|
match db_health {
|
||||||
|
Ok(_) => checks.push(DiagnosticCheck {
|
||||||
|
check_name: "sqlite_persistence".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "connected_and_healthy".to_string(),
|
||||||
|
expected_value: Some("connected_and_healthy".to_string()),
|
||||||
|
diagnostic_message: "SQLite WAL persistence layer is responsive".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
}),
|
||||||
|
Err(e) => checks.push(DiagnosticCheck {
|
||||||
|
check_name: "sqlite_persistence".to_string(),
|
||||||
|
status: DiagnosticStatus::Fail,
|
||||||
|
observed_value: format!("error: {e}"),
|
||||||
|
expected_value: Some("connected_and_healthy".to_string()),
|
||||||
|
diagnostic_message: "Database connectivity failure".to_string(),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Verify database file permissions and disk space".to_string(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "system".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Network subsystem diagnostics.
|
||||||
|
pub async fn diagnose_network(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
|
||||||
|
// Interface device list
|
||||||
|
if let Ok(devs) = std::fs::read_to_string("/proc/net/dev") {
|
||||||
|
let iface_names: Vec<String> = devs
|
||||||
|
.lines()
|
||||||
|
.skip(2)
|
||||||
|
.filter_map(|l| l.split(':').next().map(|s| s.trim().to_string()))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "linux_network_interfaces".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: format!(
|
||||||
|
"{} interfaces ({})",
|
||||||
|
iface_names.len(),
|
||||||
|
iface_names.join(", ")
|
||||||
|
),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "Network interfaces discovered in kernel".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS Configuration
|
||||||
|
let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
|
||||||
|
let nameservers: Vec<&str> = resolv
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.starts_with("nameserver"))
|
||||||
|
.filter_map(|l| l.split_whitespace().nth(1))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if nameservers.is_empty() {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "dns_nameservers".to_string(),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: "none_configured".to_string(),
|
||||||
|
expected_value: Some("valid nameserver entries".to_string()),
|
||||||
|
diagnostic_message: "No DNS nameservers found in /etc/resolv.conf".to_string(),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Configure DNS servers in /etc/resolv.conf or interface settings".to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "dns_nameservers".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: nameservers.join(", "),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "System DNS nameservers configured".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "network".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WAN and external reachability diagnostics.
|
||||||
|
pub async fn diagnose_wan(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
|
||||||
|
// Default Route check
|
||||||
|
let routes = std::fs::read_to_string("/proc/net/route").unwrap_or_default();
|
||||||
|
let has_default_gateway = routes.lines().skip(1).any(|l| {
|
||||||
|
let cols: Vec<&str> = l.split_whitespace().collect();
|
||||||
|
cols.len() > 1 && cols[1] == "00000000"
|
||||||
|
});
|
||||||
|
|
||||||
|
if has_default_gateway {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "default_gateway_route".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "default_gateway_present".to_string(),
|
||||||
|
expected_value: Some("default_gateway_present".to_string()),
|
||||||
|
diagnostic_message: "Default route to WAN/gateway is present".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "default_gateway_route".to_string(),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: "missing_default_gateway".to_string(),
|
||||||
|
expected_value: Some("default_gateway_present".to_string()),
|
||||||
|
diagnostic_message:
|
||||||
|
"No default gateway (0.0.0.0/0) detected in kernel routing table".to_string(),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Verify network connection or add a default route using 'nx9-wg route add'"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "wan".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WireGuard interface diagnostics.
|
||||||
|
pub async fn diagnose_wireguard(
|
||||||
|
&self,
|
||||||
|
interface_name: Option<&str>,
|
||||||
|
) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let interfaces = self.state.store.list_interfaces().await?;
|
||||||
|
|
||||||
|
if interfaces.is_empty() {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "configured_interfaces".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "0 interfaces".to_string(),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "No WireGuard interfaces configured yet".to_string(),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Create an interface using 'nx9-wg interface create'".to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for iface in &interfaces {
|
||||||
|
if interface_name.is_some_and(|target| iface.name != target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let live_stats = self
|
||||||
|
.wg_engine
|
||||||
|
.get_interface_stats(&iface.name)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
match live_stats {
|
||||||
|
Some(stats) => {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("interface_{}_status", iface.name),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: format!(
|
||||||
|
"active: port {}, peers {}",
|
||||||
|
stats.listen_port,
|
||||||
|
stats.peers.len()
|
||||||
|
),
|
||||||
|
expected_value: Some(format!("port {}", iface.listen_port)),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Interface '{}' is running and responsive",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if iface.enabled {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("interface_{}_status", iface.name),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: "down_or_uninitialized".to_string(),
|
||||||
|
expected_value: Some("running".to_string()),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Interface '{}' is enabled in database but not active in kernel",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Run 'nx9-wg reconcile apply' to synchronize interface to kernel"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("interface_{}_status", iface.name),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "administratively_disabled".to_string(),
|
||||||
|
expected_value: Some("disabled".to_string()),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Interface '{}' is disabled as intended",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "wireguard".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single peer diagnostics.
|
||||||
|
pub async fn diagnose_peer(&self, peer_id: Uuid) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let peer = self.state.store.get_peer(peer_id).await?;
|
||||||
|
|
||||||
|
match peer {
|
||||||
|
Some(p) => {
|
||||||
|
// Peer State
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "lifecycle_state".to_string(),
|
||||||
|
status: match p.state {
|
||||||
|
nx9_wg_core::types::wireguard::PeerState::Active => DiagnosticStatus::Pass,
|
||||||
|
nx9_wg_core::types::wireguard::PeerState::Disabled => {
|
||||||
|
DiagnosticStatus::Warning
|
||||||
|
}
|
||||||
|
nx9_wg_core::types::wireguard::PeerState::Expired => {
|
||||||
|
DiagnosticStatus::Warning
|
||||||
|
}
|
||||||
|
nx9_wg_core::types::wireguard::PeerState::Revoked => DiagnosticStatus::Fail,
|
||||||
|
},
|
||||||
|
observed_value: p.state.to_string(),
|
||||||
|
expected_value: Some("active".to_string()),
|
||||||
|
diagnostic_message: format!("Peer '{}' is in '{}' state", p.name, p.state),
|
||||||
|
remediation_hint: match p.state {
|
||||||
|
nx9_wg_core::types::wireguard::PeerState::Expired => {
|
||||||
|
Some("Extend or renew peer expiration date".to_string())
|
||||||
|
}
|
||||||
|
nx9_wg_core::types::wireguard::PeerState::Disabled => {
|
||||||
|
Some("Enable peer using 'nx9-wg peer enable'".to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Address allocation
|
||||||
|
let v4_str = p
|
||||||
|
.address_v4
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_else(|| "none".to_string());
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "assigned_address".to_string(),
|
||||||
|
status: if p.address_v4.is_some() {
|
||||||
|
DiagnosticStatus::Pass
|
||||||
|
} else {
|
||||||
|
DiagnosticStatus::Warning
|
||||||
|
},
|
||||||
|
observed_value: v4_str,
|
||||||
|
expected_value: Some("valid CIDR".to_string()),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Peer address assignment: allowed_ips={}",
|
||||||
|
p.allowed_ips
|
||||||
|
),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expiration timeline
|
||||||
|
if let Some(exp) = p.expires_at {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
if exp <= now {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "expiration_status".to_string(),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: format!("expired_at_{exp}"),
|
||||||
|
expected_value: Some("future_expiration".to_string()),
|
||||||
|
diagnostic_message: "Peer expiration timestamp has elapsed".to_string(),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Update peer expiration date to restore access".to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "expiration_status".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: format!("valid_until_{exp}"),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "Peer credential is within validity period"
|
||||||
|
.to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "peer_lookup".to_string(),
|
||||||
|
status: DiagnosticStatus::Fail,
|
||||||
|
observed_value: "not_found".to_string(),
|
||||||
|
expected_value: Some("valid_peer_record".to_string()),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Peer '{peer_id}' does not exist in SQLite database"
|
||||||
|
),
|
||||||
|
remediation_hint: Some("Verify peer ID with 'nx9-wg peer list'".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: format!("peer:{}", peer_id),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routing subsystem diagnostics.
|
||||||
|
pub async fn diagnose_routing(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let routes = self.state.store.list_routes().await?;
|
||||||
|
let active_routes: Vec<_> = routes.iter().filter(|r| r.enabled).collect();
|
||||||
|
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "configured_routes".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: format!("{} total ({} active)", routes.len(), active_routes.len()),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "Kernel routing rules configured in database".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "routing".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IP packet forwarding diagnostics.
|
||||||
|
pub async fn diagnose_forwarding(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let fwd = self.net_engine.get_forwarding_status().await;
|
||||||
|
|
||||||
|
match fwd {
|
||||||
|
Ok(status) => {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "ipv4_forwarding".to_string(),
|
||||||
|
status: if status.ipv4_enabled { DiagnosticStatus::Pass } else { DiagnosticStatus::Warning },
|
||||||
|
observed_value: if status.ipv4_enabled { "enabled".to_string() } else { "disabled".to_string() },
|
||||||
|
expected_value: Some("enabled".to_string()),
|
||||||
|
diagnostic_message: if status.ipv4_enabled {
|
||||||
|
"IPv4 packet forwarding is enabled in sysctl".to_string()
|
||||||
|
} else {
|
||||||
|
"IPv4 packet forwarding is disabled in sysctl; VPN clients cannot route traffic".to_string()
|
||||||
|
},
|
||||||
|
remediation_hint: if !status.ipv4_enabled {
|
||||||
|
Some("Enable IP forwarding with 'nx9-wg forwarding enable'".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "forwarding_sysctl_read".to_string(),
|
||||||
|
status: DiagnosticStatus::Fail,
|
||||||
|
observed_value: format!("error: {e}"),
|
||||||
|
expected_value: Some("readable".to_string()),
|
||||||
|
diagnostic_message: "Failed to read kernel forwarding state".to_string(),
|
||||||
|
remediation_hint: Some("Verify /proc filesystem is mounted".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "forwarding".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Firewall subsystem diagnostics.
|
||||||
|
pub async fn diagnose_firewall(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let rules = self.state.store.list_firewall_rules().await?;
|
||||||
|
let active_rules: Vec<_> = rules.iter().filter(|r| r.enabled).collect();
|
||||||
|
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "firewall_rules_count".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: format!("{} total ({} active)", rules.len(), active_rules.len()),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "Configured nftables packet filtering rules".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let active_nft = self.net_engine.get_active_nftables_ruleset().await;
|
||||||
|
match active_nft {
|
||||||
|
Ok(ruleset) => {
|
||||||
|
let has_table = ruleset.contains("table inet nx9_wg");
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "nftables_table_nx9_wg".to_string(),
|
||||||
|
status: if has_table {
|
||||||
|
DiagnosticStatus::Pass
|
||||||
|
} else {
|
||||||
|
DiagnosticStatus::Warning
|
||||||
|
},
|
||||||
|
observed_value: if has_table {
|
||||||
|
"active".to_string()
|
||||||
|
} else {
|
||||||
|
"not_loaded".to_string()
|
||||||
|
},
|
||||||
|
expected_value: Some("active".to_string()),
|
||||||
|
diagnostic_message: "Dedicated table inet nx9_wg presence in kernel nftables"
|
||||||
|
.to_string(),
|
||||||
|
remediation_hint: if !has_table {
|
||||||
|
Some("Synchronize firewall with 'nx9-wg firewall sync'".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "nftables_access".to_string(),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: format!("error: {e}"),
|
||||||
|
expected_value: Some("accessible".to_string()),
|
||||||
|
diagnostic_message: "Could not inspect live nftables ruleset".to_string(),
|
||||||
|
remediation_hint: Some("Verify CAP_NET_ADMIN / root permissions".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "firewall".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NAT masquerade diagnostics.
|
||||||
|
pub async fn diagnose_nat(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let nat_setting = self
|
||||||
|
.state
|
||||||
|
.store
|
||||||
|
.get_setting("enable_nat")
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value == "true" || s.value == "1")
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "nat_setting".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: if nat_setting {
|
||||||
|
"enabled".to_string()
|
||||||
|
} else {
|
||||||
|
"disabled".to_string()
|
||||||
|
},
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: "NAT masquerade setting configured in database".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "nat".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MTU consistency and client profile diagnostics.
|
||||||
|
pub async fn diagnose_mtu(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let interfaces = self.state.store.list_interfaces().await?;
|
||||||
|
let peers = self.state.store.list_all_peers().await?;
|
||||||
|
|
||||||
|
// 1. Interface MTU Checks
|
||||||
|
for iface in &interfaces {
|
||||||
|
let mtu = iface.mtu.unwrap_or(1420);
|
||||||
|
if mtu > 1500 {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("server_mtu_{}", iface.name),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: format!("{mtu} bytes (jumbo)"),
|
||||||
|
expected_value: Some("1420 bytes (<= 1500)".to_string()),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Interface '{}' MTU ({mtu}) exceeds standard physical MTU 1500; may cause fragmentation on WAN egress",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Set WireGuard server MTU to 1420 to prevent packet fragmentation".to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if mtu < 1280 {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("server_mtu_{}", iface.name),
|
||||||
|
status: DiagnosticStatus::Fail,
|
||||||
|
observed_value: format!("{mtu} bytes"),
|
||||||
|
expected_value: Some(">= 1280 bytes".to_string()),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Interface '{}' MTU ({mtu}) is below the IPv6 minimum MTU (1280)",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Increase interface MTU to at least 1280 bytes".to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("server_mtu_{}", iface.name),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: format!("{mtu} bytes"),
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Server interface '{}' MTU ({mtu}) is within safe WAN limits (1280-1500)",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check peer MTU consistency against server MTU
|
||||||
|
let iface_peers: Vec<_> = peers
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.interface_id == iface.id)
|
||||||
|
.collect();
|
||||||
|
for p in iface_peers {
|
||||||
|
if let Some(peer_mtu) = p.mtu.filter(|&pm| pm > mtu) {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("peer_mtu_{}", p.name),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: format!("{peer_mtu} bytes"),
|
||||||
|
expected_value: Some(format!("<= {mtu} bytes")),
|
||||||
|
diagnostic_message: format!(
|
||||||
|
"Peer '{}' MTU ({peer_mtu}) exceeds server interface '{}' MTU ({mtu})",
|
||||||
|
p.name, iface.name
|
||||||
|
),
|
||||||
|
remediation_hint: Some(
|
||||||
|
"Align peer MTU to be equal to or less than server interface MTU"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Client Profile Recommendations Check
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "client_profile_mobile_recommendation".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "1280 bytes (keepalive: 25s)".to_string(),
|
||||||
|
expected_value: Some("1280 bytes".to_string()),
|
||||||
|
diagnostic_message: "Recommended MTU for mobile/cellular connections is 1280 to prevent carrier fragmentation".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "client_profile_cgnat_recommendation".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "1360 bytes (keepalive: 25s)".to_string(),
|
||||||
|
expected_value: Some("1360 bytes".to_string()),
|
||||||
|
diagnostic_message: "Recommended MTU for CGNAT connections is 1360 to accommodate carrier-grade NAT encapsulation".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "client_profile_wifi_recommendation".to_string(),
|
||||||
|
status: DiagnosticStatus::Pass,
|
||||||
|
observed_value: "1420 bytes (keepalive: 25s)".to_string(),
|
||||||
|
expected_value: Some("1420 bytes".to_string()),
|
||||||
|
diagnostic_message: "Recommended MTU for standard Wi-Fi and wired connections is 1420 bytes".to_string(),
|
||||||
|
remediation_hint: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "mtu".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciliation drift diagnostics.
|
||||||
|
pub async fn diagnose_reconciliation(&self) -> ApiResult<DiagnosticReport> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let plan = self.reconciler.plan().await?;
|
||||||
|
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: "overall_drift".to_string(),
|
||||||
|
status: if plan.has_drift {
|
||||||
|
DiagnosticStatus::Warning
|
||||||
|
} else {
|
||||||
|
DiagnosticStatus::Pass
|
||||||
|
},
|
||||||
|
observed_value: if plan.has_drift {
|
||||||
|
format!("{} drift actions pending", plan.actions.len())
|
||||||
|
} else {
|
||||||
|
"zero_drift".to_string()
|
||||||
|
},
|
||||||
|
expected_value: Some("zero_drift".to_string()),
|
||||||
|
diagnostic_message: if plan.has_drift {
|
||||||
|
"Discrepancies detected between SQLite desired state and Linux kernel state"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
"SQLite desired state and live kernel state are in full synchronization".to_string()
|
||||||
|
},
|
||||||
|
remediation_hint: if plan.has_drift {
|
||||||
|
Some("Execute 'nx9-wg reconcile apply' to synchronize changes".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for action in plan.actions {
|
||||||
|
checks.push(DiagnosticCheck {
|
||||||
|
check_name: format!("drift:{}:{}", action.subsystem, action.action_type),
|
||||||
|
status: DiagnosticStatus::Warning,
|
||||||
|
observed_value: action.resource_id,
|
||||||
|
expected_value: None,
|
||||||
|
diagnostic_message: action.description,
|
||||||
|
remediation_hint: Some("Run 'nx9-wg reconcile apply'".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall = Self::calculate_overall_status(&checks);
|
||||||
|
Ok(DiagnosticReport {
|
||||||
|
subsystem: "reconciliation".to_string(),
|
||||||
|
timestamp: Utc::now().naive_utc(),
|
||||||
|
overall_status: overall,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run full diagnosis across all subsystems.
|
||||||
|
pub async fn diagnose_all(&self) -> ApiResult<Vec<DiagnosticReport>> {
|
||||||
|
let mut reports = Vec::new();
|
||||||
|
reports.push(self.diagnose_system().await?);
|
||||||
|
reports.push(self.diagnose_network().await?);
|
||||||
|
reports.push(self.diagnose_wan().await?);
|
||||||
|
reports.push(self.diagnose_wireguard(None).await?);
|
||||||
|
reports.push(self.diagnose_routing().await?);
|
||||||
|
reports.push(self.diagnose_forwarding().await?);
|
||||||
|
reports.push(self.diagnose_firewall().await?);
|
||||||
|
reports.push(self.diagnose_nat().await?);
|
||||||
|
reports.push(self.diagnose_mtu().await?);
|
||||||
|
reports.push(self.diagnose_reconciliation().await?);
|
||||||
|
Ok(reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_overall_status(checks: &[DiagnosticCheck]) -> DiagnosticStatus {
|
||||||
|
if checks.iter().any(|c| c.status == DiagnosticStatus::Fail) {
|
||||||
|
DiagnosticStatus::Fail
|
||||||
|
} else if checks.iter().any(|c| c.status == DiagnosticStatus::Warning) {
|
||||||
|
DiagnosticStatus::Warning
|
||||||
|
} else {
|
||||||
|
DiagnosticStatus::Pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! API error types and response structures.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Unified API result type.
|
||||||
|
pub type ApiResult<T> = std::result::Result<T, ApiError>;
|
||||||
|
|
||||||
|
/// API-level errors.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ApiError {
|
||||||
|
#[error("unauthenticated: {0}")]
|
||||||
|
Unauthenticated(String),
|
||||||
|
|
||||||
|
#[error("forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("rate limited: {0}")]
|
||||||
|
RateLimited(String),
|
||||||
|
|
||||||
|
#[error("internal server error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
|
||||||
|
#[error("subsystem unavailable: {0}")]
|
||||||
|
Unavailable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nx9_wg_db::DbError> for ApiError {
|
||||||
|
fn from(err: nx9_wg_db::DbError) -> Self {
|
||||||
|
match err {
|
||||||
|
nx9_wg_db::DbError::NotFound(msg) => Self::NotFound(msg),
|
||||||
|
nx9_wg_db::DbError::Conflict(msg) => Self::Conflict(msg),
|
||||||
|
nx9_wg_db::DbError::ConstraintViolation(msg) => Self::BadRequest(msg),
|
||||||
|
nx9_wg_db::DbError::Validation(msg) => Self::Validation(msg),
|
||||||
|
nx9_wg_db::DbError::Sqlx(e) => {
|
||||||
|
tracing::error!("database error: {e}");
|
||||||
|
Self::Internal("A database error occurred".to_string())
|
||||||
|
}
|
||||||
|
nx9_wg_db::DbError::Migration(msg) => Self::Internal(format!("Migration error: {msg}")),
|
||||||
|
nx9_wg_db::DbError::Internal(msg) => Self::Internal(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nx9_wg_core::error::Nx9Error> for ApiError {
|
||||||
|
fn from(err: nx9_wg_core::error::Nx9Error) -> Self {
|
||||||
|
match err {
|
||||||
|
nx9_wg_core::error::Nx9Error::Validation(msg) => Self::Validation(msg),
|
||||||
|
nx9_wg_core::error::Nx9Error::Auth(msg) => Self::Unauthenticated(msg),
|
||||||
|
nx9_wg_core::error::Nx9Error::Crypto(msg) => Self::Internal(msg),
|
||||||
|
nx9_wg_core::error::Nx9Error::Config(msg) => Self::BadRequest(msg),
|
||||||
|
_ => Self::Internal(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard JSON error envelope.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: ErrorBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error details in the error response.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorBody {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, code, message) = match &self {
|
||||||
|
Self::Unauthenticated(msg) => {
|
||||||
|
(StatusCode::UNAUTHORIZED, "UNAUTHENTICATED", msg.clone())
|
||||||
|
}
|
||||||
|
Self::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
|
||||||
|
Self::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
|
||||||
|
Self::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
|
||||||
|
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.clone()),
|
||||||
|
Self::Validation(msg) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
msg.clone(),
|
||||||
|
),
|
||||||
|
Self::RateLimited(msg) => (StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED", msg.clone()),
|
||||||
|
Self::Internal(msg) => {
|
||||||
|
tracing::error!("Internal server error: {msg}");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
"An unexpected error occurred".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Unavailable(msg) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"SUBSYSTEM_UNAVAILABLE",
|
||||||
|
msg.clone(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = Json(ErrorResponse {
|
||||||
|
error: ErrorBody {
|
||||||
|
code: code.to_string(),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//! Axum REST API, WebSocket server, and system services for nx9-wg.
|
||||||
|
|
||||||
|
pub mod allocator;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod diagnostics;
|
||||||
|
pub mod error;
|
||||||
|
pub mod profile_resolver;
|
||||||
|
pub mod reconciliation;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
pub use allocator::{IpAllocator, NetworkAllocation};
|
||||||
|
pub use auth::{
|
||||||
|
AuthService, AuthenticatedAdmin, BootstrapOptions, BootstrapResult, bootstrap_admin,
|
||||||
|
};
|
||||||
|
pub use backup::BackupService;
|
||||||
|
pub use diagnostics::DiagnosticsService;
|
||||||
|
pub use error::{ApiError, ApiResult, ErrorBody, ErrorResponse};
|
||||||
|
pub use profile_resolver::ClientProfileResolver;
|
||||||
|
pub use reconciliation::{
|
||||||
|
ReconciliationAction, ReconciliationEngine, ReconciliationPlan, ReconciliationReport,
|
||||||
|
};
|
||||||
|
pub use routes::build_api_router;
|
||||||
|
pub use state::{AppState, SystemEvent};
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
//! Native client environment and MTU profile resolution service.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use nx9_wg_core::types::client_profile::{
|
||||||
|
ClientProfile, ConnectionType, DeviceCategory, NatType, ResolvedClientProfile,
|
||||||
|
};
|
||||||
|
use nx9_wg_core::validation::validate_client_mtu;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
|
||||||
|
/// Options for resolving a client profile.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProfileResolutionOptions<'a> {
|
||||||
|
pub provider: Option<&'a str>,
|
||||||
|
pub device: Option<DeviceCategory>,
|
||||||
|
pub connection: Option<ConnectionType>,
|
||||||
|
pub nat: Option<NatType>,
|
||||||
|
pub manual_mtu: Option<u16>,
|
||||||
|
pub profile_id: Option<&'a str>,
|
||||||
|
pub server_mtu: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for deterministic client configuration profile resolution.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ClientProfileResolver;
|
||||||
|
|
||||||
|
impl ClientProfileResolver {
|
||||||
|
/// Resolve an authoritative `ResolvedClientProfile` with structured options.
|
||||||
|
pub async fn resolve_opts(
|
||||||
|
store: &Store,
|
||||||
|
opts: ProfileResolutionOptions<'_>,
|
||||||
|
) -> Result<ResolvedClientProfile, ApiError> {
|
||||||
|
let (base_profile, is_custom_id) = if let Some(p_id) = opts.profile_id {
|
||||||
|
let p = store
|
||||||
|
.get_client_profile(p_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("client profile '{p_id}' not found")))?;
|
||||||
|
(p, true)
|
||||||
|
} else {
|
||||||
|
let resolved = Self::match_best_profile(
|
||||||
|
store,
|
||||||
|
opts.provider,
|
||||||
|
opts.device,
|
||||||
|
opts.connection,
|
||||||
|
opts.nat,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
(resolved, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_manually_overridden = opts.manual_mtu.is_some();
|
||||||
|
let final_mtu = if let Some(m) = opts.manual_mtu {
|
||||||
|
validate_client_mtu(m).map_err(|e| ApiError::Validation(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
base_profile.mtu
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct warnings if MTU might cause path issues
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if is_manually_overridden {
|
||||||
|
warnings.push("Client MTU has been manually overridden by administrator.".to_string());
|
||||||
|
}
|
||||||
|
if let Some(s_mtu) = opts.server_mtu.filter(|&sm| final_mtu > sm) {
|
||||||
|
warnings.push(format!(
|
||||||
|
"Client MTU ({final_mtu}) exceeds server interface MTU ({s_mtu}), which may cause packet truncation or fragmentation."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if final_mtu > 1500 {
|
||||||
|
warnings.push(format!(
|
||||||
|
"Client MTU ({final_mtu}) is in jumbo frame range and may cause drops on standard WAN paths."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let warning = if warnings.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(warnings.join(" "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_connection = if is_custom_id {
|
||||||
|
base_profile.connection_type
|
||||||
|
} else {
|
||||||
|
opts.connection.unwrap_or(base_profile.connection_type)
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_nat = if is_custom_id {
|
||||||
|
base_profile.nat_type
|
||||||
|
} else {
|
||||||
|
opts.nat.unwrap_or(base_profile.nat_type)
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_device = if is_custom_id {
|
||||||
|
base_profile.device
|
||||||
|
} else {
|
||||||
|
opts.device.or(base_profile.device)
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_provider = if is_custom_id {
|
||||||
|
base_profile.provider
|
||||||
|
} else {
|
||||||
|
opts.provider
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or(base_profile.provider)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ResolvedClientProfile {
|
||||||
|
mtu: final_mtu,
|
||||||
|
persistent_keepalive: base_profile.persistent_keepalive,
|
||||||
|
dns: base_profile.dns,
|
||||||
|
is_manually_overridden,
|
||||||
|
applied_profile_id: base_profile.id,
|
||||||
|
applied_profile_name: base_profile.name,
|
||||||
|
connection_type: resolved_connection,
|
||||||
|
nat_type: resolved_nat,
|
||||||
|
device: resolved_device,
|
||||||
|
provider: resolved_provider,
|
||||||
|
warning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience wrapper for resolving a client profile.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn resolve(
|
||||||
|
store: &Store,
|
||||||
|
provider: Option<&str>,
|
||||||
|
device: Option<DeviceCategory>,
|
||||||
|
connection: Option<ConnectionType>,
|
||||||
|
nat: Option<NatType>,
|
||||||
|
manual_mtu: Option<u16>,
|
||||||
|
profile_id: Option<&str>,
|
||||||
|
server_mtu: Option<u16>,
|
||||||
|
) -> Result<ResolvedClientProfile, ApiError> {
|
||||||
|
Self::resolve_opts(
|
||||||
|
store,
|
||||||
|
ProfileResolutionOptions {
|
||||||
|
provider,
|
||||||
|
device,
|
||||||
|
connection,
|
||||||
|
nat,
|
||||||
|
manual_mtu,
|
||||||
|
profile_id,
|
||||||
|
server_mtu,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match the best candidate profile from Store using a deterministic specificity score.
|
||||||
|
async fn match_best_profile(
|
||||||
|
store: &Store,
|
||||||
|
provider: Option<&str>,
|
||||||
|
device: Option<DeviceCategory>,
|
||||||
|
connection: Option<ConnectionType>,
|
||||||
|
nat: Option<NatType>,
|
||||||
|
) -> Result<ClientProfile, ApiError> {
|
||||||
|
let profiles = store.list_client_profiles().await?;
|
||||||
|
|
||||||
|
let mut scored_profiles: Vec<(i32, bool, String, ClientProfile)> = Vec::new();
|
||||||
|
|
||||||
|
for p in profiles {
|
||||||
|
let mut score = 0;
|
||||||
|
|
||||||
|
// Provider matching
|
||||||
|
if let Some(req_p) = provider {
|
||||||
|
if let Some(ref prof_p) = p.provider {
|
||||||
|
if prof_p.eq_ignore_ascii_case(req_p) {
|
||||||
|
score += 100;
|
||||||
|
} else {
|
||||||
|
// Specified provider did not match
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if p.provider.is_some() {
|
||||||
|
// If no provider requested, skip provider-specific profiles
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device matching
|
||||||
|
if let Some(req_d) = device {
|
||||||
|
if let Some(prof_d) = p.device {
|
||||||
|
if prof_d == req_d {
|
||||||
|
score += 40;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if p.device.is_some() {
|
||||||
|
// If no device requested, skip device-specific profiles
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection matching
|
||||||
|
if let Some(req_c) = connection {
|
||||||
|
if p.connection_type == req_c {
|
||||||
|
score += 20;
|
||||||
|
} else if p.connection_type == ConnectionType::Other {
|
||||||
|
score += 5;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if p.connection_type == ConnectionType::Web {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NAT matching
|
||||||
|
if let Some(req_n) = nat {
|
||||||
|
if p.nat_type == req_n {
|
||||||
|
score += 20;
|
||||||
|
} else if p.nat_type == NatType::Unknown {
|
||||||
|
score += 5;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if p.nat_type == NatType::Unknown {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom profile slight preference
|
||||||
|
if !p.is_builtin {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
scored_profiles.push((score, p.is_builtin, p.id.clone(), p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by: score descending, is_builtin ascending (custom first), ID ascending (deterministic)
|
||||||
|
scored_profiles.sort_by(|a, b| {
|
||||||
|
b.0.cmp(&a.0)
|
||||||
|
.then_with(|| a.1.cmp(&b.1))
|
||||||
|
.then_with(|| a.2.cmp(&b.2))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some((_, _, _, best)) = scored_profiles.into_iter().next() {
|
||||||
|
return Ok(best);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback default profile if database somehow had zero matches
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
Ok(ClientProfile {
|
||||||
|
id: "default-fallback".to_string(),
|
||||||
|
name: "Default Fallback".to_string(),
|
||||||
|
provider: None,
|
||||||
|
device: None,
|
||||||
|
connection_type: ConnectionType::Web,
|
||||||
|
nat_type: NatType::Unknown,
|
||||||
|
mtu: 1420,
|
||||||
|
dns: None,
|
||||||
|
persistent_keepalive: Some(25),
|
||||||
|
is_builtin: true,
|
||||||
|
description: Some("Universal fallback client profile".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolver_matrix() {
|
||||||
|
let store = Store::connect_in_memory().await.unwrap();
|
||||||
|
store.migrate().await.unwrap();
|
||||||
|
|
||||||
|
// 1. Mobile default
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ConnectionType::Mobile),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.mtu, 1280);
|
||||||
|
assert_eq!(res.connection_type, ConnectionType::Mobile);
|
||||||
|
assert!(!res.is_manually_overridden);
|
||||||
|
|
||||||
|
// 2. Wi-Fi default
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ConnectionType::Wifi),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.mtu, 1420);
|
||||||
|
assert_eq!(res.connection_type, ConnectionType::Wifi);
|
||||||
|
|
||||||
|
// 3. CGNAT default
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(NatType::Cgnat),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.mtu, 1360);
|
||||||
|
assert_eq!(res.nat_type, NatType::Cgnat);
|
||||||
|
|
||||||
|
// 4. Android on Mobile
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
None,
|
||||||
|
Some(DeviceCategory::Android),
|
||||||
|
Some(ConnectionType::Mobile),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.mtu, 1280);
|
||||||
|
assert_eq!(res.applied_profile_id, "android-mobile");
|
||||||
|
|
||||||
|
// 5. Provider specific (Starlink CGNAT)
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
Some("starlink"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(NatType::Cgnat),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.mtu, 1360);
|
||||||
|
assert_eq!(res.applied_profile_id, "starlink-cgnat");
|
||||||
|
|
||||||
|
// 6. Explicit Profile ID
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("default-wifi"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.mtu, 1420);
|
||||||
|
assert_eq!(res.applied_profile_id, "default-wifi");
|
||||||
|
|
||||||
|
// 7. Manual MTU Override
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ConnectionType::Mobile),
|
||||||
|
None,
|
||||||
|
Some(1350),
|
||||||
|
None,
|
||||||
|
Some(1420),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.mtu, 1350);
|
||||||
|
assert!(res.is_manually_overridden);
|
||||||
|
assert!(
|
||||||
|
res.warning
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("manually overridden")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Server MTU warning
|
||||||
|
let res = ClientProfileResolver::resolve(
|
||||||
|
&store,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ConnectionType::Wifi),
|
||||||
|
None,
|
||||||
|
Some(1450),
|
||||||
|
None,
|
||||||
|
Some(1420),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
res.warning
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("exceeds server interface MTU")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9. Invalid manual MTU rejection
|
||||||
|
assert!(
|
||||||
|
ClientProfileResolver::resolve(&store, None, None, None, None, Some(1200), None, None,)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
//! Deterministic reconciliation engine between SQLite desired state and live Linux kernel state.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::state::{AppState, SystemEvent};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
|
use nx9_wg_core::types::wireguard::PeerState;
|
||||||
|
use nx9_wg_network::NetworkEngine;
|
||||||
|
use nx9_wireguard::WireGuardEngine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Individual action proposed or taken by the reconciler.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReconciliationAction {
|
||||||
|
pub subsystem: String,
|
||||||
|
pub resource_id: String,
|
||||||
|
pub action_type: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan describing detected drift and planned remediation steps.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ReconciliationPlan {
|
||||||
|
pub has_drift: bool,
|
||||||
|
pub actions: Vec<ReconciliationAction>,
|
||||||
|
pub interface_changes: usize,
|
||||||
|
pub peer_changes: usize,
|
||||||
|
pub route_changes: usize,
|
||||||
|
pub firewall_changes: usize,
|
||||||
|
pub forwarding_changes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final report of an executed reconciliation cycle.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReconciliationReport {
|
||||||
|
pub success: bool,
|
||||||
|
pub executed_actions: usize,
|
||||||
|
pub details: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciliation engine coordinating SQLite store, WireGuard engine, and Network engine.
|
||||||
|
pub struct ReconciliationEngine {
|
||||||
|
state: AppState,
|
||||||
|
wg_engine: Arc<dyn WireGuardEngine>,
|
||||||
|
net_engine: Arc<dyn NetworkEngine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReconciliationEngine {
|
||||||
|
/// Create a new reconciliation engine.
|
||||||
|
pub fn new(
|
||||||
|
state: AppState,
|
||||||
|
wg_engine: Arc<dyn WireGuardEngine>,
|
||||||
|
net_engine: Arc<dyn NetworkEngine>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
wg_engine,
|
||||||
|
net_engine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sweep expired active peers in SQLite and update their state.
|
||||||
|
pub async fn sweep_expired_peers(&self) -> ApiResult<usize> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let expired_peers = self.state.store.get_expired_active_peers(now).await?;
|
||||||
|
let count = expired_peers.len();
|
||||||
|
|
||||||
|
for peer in expired_peers {
|
||||||
|
self.state.store.mark_peer_expired(peer.id).await?;
|
||||||
|
let _ = self
|
||||||
|
.state
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::PeerExpire,
|
||||||
|
"reconciliation",
|
||||||
|
Some("peer"),
|
||||||
|
Some(&peer.id.to_string()),
|
||||||
|
Some(&format!(
|
||||||
|
"Peer '{}' reached expiration date and transitioned to expired",
|
||||||
|
peer.name
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: peer.id.to_string(),
|
||||||
|
action: "expired".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute reconciliation plan by comparing desired state against live telemetry.
|
||||||
|
pub async fn plan(&self) -> ApiResult<ReconciliationPlan> {
|
||||||
|
// Run expiration sweep first so desired state reflects current time
|
||||||
|
let _ = self.sweep_expired_peers().await;
|
||||||
|
|
||||||
|
let mut plan = ReconciliationPlan::default();
|
||||||
|
|
||||||
|
// 1. Interfaces and Peers
|
||||||
|
let desired_interfaces = self.state.store.list_interfaces().await?;
|
||||||
|
let live_interfaces = self.wg_engine.list_interfaces().await.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to query live WireGuard interfaces: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for iface in &desired_interfaces {
|
||||||
|
if iface.enabled {
|
||||||
|
let live_stats = self
|
||||||
|
.wg_engine
|
||||||
|
.get_interface_stats(&iface.name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to get live stats for '{}': {e}",
|
||||||
|
iface.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let live_peer_keys: Vec<String> = live_stats
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.peers.iter().map(|p| p.public_key.clone()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
match live_stats.as_ref() {
|
||||||
|
Some(stats) => {
|
||||||
|
if stats.public_key != iface.public_key.as_str()
|
||||||
|
|| stats.listen_port != iface.listen_port
|
||||||
|
{
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "wireguard".to_string(),
|
||||||
|
resource_id: iface.id.to_string(),
|
||||||
|
action_type: "update_interface".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Interface '{}' configuration drift detected; update listen port / keys",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.interface_changes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "wireguard".to_string(),
|
||||||
|
resource_id: iface.id.to_string(),
|
||||||
|
action_type: "create_interface".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Interface '{}' missing in kernel; create and sync",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.interface_changes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check peers (only Active desired peers should be live)
|
||||||
|
let desired_peers = self.state.store.list_peers_for_interface(iface.id).await?;
|
||||||
|
let active_desired_peers: Vec<_> = desired_peers
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.state == PeerState::Active)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for p in &active_desired_peers {
|
||||||
|
if !live_peer_keys.contains(&p.public_key.as_str().to_string()) {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "wireguard".to_string(),
|
||||||
|
resource_id: p.id.to_string(),
|
||||||
|
action_type: "add_peer".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Peer '{}' ({}) missing in live interface",
|
||||||
|
p.name,
|
||||||
|
p.public_key.as_str()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.peer_changes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for live peers that are no longer active in database
|
||||||
|
if let Some(stats) = live_stats.as_ref() {
|
||||||
|
let active_keys: Vec<String> = active_desired_peers
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.public_key.as_str().to_string())
|
||||||
|
.collect();
|
||||||
|
for live_p in &stats.peers {
|
||||||
|
if !active_keys.contains(&live_p.public_key) {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "wireguard".to_string(),
|
||||||
|
resource_id: live_p.public_key.clone(),
|
||||||
|
action_type: "remove_inactive_peer".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Peer ({}) is inactive/expired/deleted in database but present in kernel",
|
||||||
|
live_p.public_key
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.peer_changes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if live_interfaces.contains(&iface.name) {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "wireguard".to_string(),
|
||||||
|
resource_id: iface.id.to_string(),
|
||||||
|
action_type: "delete_interface".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Interface '{}' is disabled in database; remove from kernel",
|
||||||
|
iface.name
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.interface_changes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Routes
|
||||||
|
let desired_routes = self.state.store.list_routes().await?;
|
||||||
|
let enabled_routes: Vec<_> = desired_routes.iter().filter(|r| r.enabled).collect();
|
||||||
|
if !enabled_routes.is_empty() {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "network".to_string(),
|
||||||
|
resource_id: "routing_table".to_string(),
|
||||||
|
action_type: "sync_routes".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Synchronize {} active routes to kernel",
|
||||||
|
enabled_routes.len()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.route_changes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Firewall and NAT
|
||||||
|
let desired_fw_rules = self.state.store.list_firewall_rules().await?;
|
||||||
|
if !desired_fw_rules.is_empty() {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "firewall".to_string(),
|
||||||
|
resource_id: "nftables".to_string(),
|
||||||
|
action_type: "sync_nftables".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"Synchronize {} firewall rules and NAT table",
|
||||||
|
desired_fw_rules.len()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
plan.firewall_changes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. IP Forwarding
|
||||||
|
let fwd_status = self
|
||||||
|
.net_engine
|
||||||
|
.get_forwarding_status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to get forwarding status: {e}")))?;
|
||||||
|
if !fwd_status.ipv4_enabled {
|
||||||
|
plan.actions.push(ReconciliationAction {
|
||||||
|
subsystem: "forwarding".to_string(),
|
||||||
|
resource_id: "ipv4_forward".to_string(),
|
||||||
|
action_type: "enable_forwarding".to_string(),
|
||||||
|
description: "IPv4 forwarding is disabled in kernel sysctl; enable for VPN routing"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
plan.forwarding_changes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.has_drift = !plan.actions.is_empty();
|
||||||
|
Ok(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute the reconciliation plan, applying changes idempotently to kernel adapters.
|
||||||
|
pub async fn apply(&self) -> ApiResult<ReconciliationReport> {
|
||||||
|
// Sweep expired peers
|
||||||
|
let _ = self.sweep_expired_peers().await;
|
||||||
|
|
||||||
|
let desired_interfaces = self.state.store.list_interfaces().await?;
|
||||||
|
let mut details = Vec::new();
|
||||||
|
|
||||||
|
// 1. Sync all active WireGuard interfaces and their peers
|
||||||
|
let mut wg_subnets = Vec::new();
|
||||||
|
for iface in &desired_interfaces {
|
||||||
|
if iface.enabled {
|
||||||
|
let peers = self.state.store.list_peers_for_interface(iface.id).await?;
|
||||||
|
self.wg_engine
|
||||||
|
.sync_interface(iface, &peers)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to sync interface '{}': {e}",
|
||||||
|
iface.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
wg_subnets.push(iface.address_v4);
|
||||||
|
if let Some(v6) = iface.address_v6 {
|
||||||
|
wg_subnets.push(v6);
|
||||||
|
}
|
||||||
|
details.push(format!(
|
||||||
|
"Synchronized interface '{}' with {} peers",
|
||||||
|
iface.name,
|
||||||
|
peers.len()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let _ = self.wg_engine.delete_interface(&iface.name).await;
|
||||||
|
details.push(format!(
|
||||||
|
"Ensured disabled interface '{}' is down",
|
||||||
|
iface.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync Routes
|
||||||
|
let routes = self.state.store.list_routes().await?;
|
||||||
|
self.net_engine
|
||||||
|
.sync_routes(&routes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to sync kernel routes: {e}")))?;
|
||||||
|
details.push(format!("Synchronized {} routing entries", routes.len()));
|
||||||
|
|
||||||
|
// 3. Sync Firewall & NAT with peer IP resolution
|
||||||
|
let raw_fw_rules = self.state.store.list_firewall_rules().await?;
|
||||||
|
let mut resolved_fw_rules = Vec::with_capacity(raw_fw_rules.len());
|
||||||
|
|
||||||
|
for mut rule in raw_fw_rules {
|
||||||
|
if let Some(peer_id) = rule.peer_id {
|
||||||
|
let peer = self.state.store.get_peer(peer_id).await.ok().flatten();
|
||||||
|
if let Some(addr) = peer
|
||||||
|
.and_then(|p| p.address_v4)
|
||||||
|
.filter(|_| rule.source.is_none() && rule.destination.is_none())
|
||||||
|
{
|
||||||
|
rule.source = Some(addr.addr().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved_fw_rules.push(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enable_nat = self
|
||||||
|
.state
|
||||||
|
.store
|
||||||
|
.get_setting("enable_nat")
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value == "true" || s.value == "1")
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
self.net_engine
|
||||||
|
.sync_firewall(&resolved_fw_rules, enable_nat, &wg_subnets)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to sync nftables firewall: {e}")))?;
|
||||||
|
details.push(format!(
|
||||||
|
"Synchronized {} firewall rules into table inet nx9_wg (NAT: {enable_nat})",
|
||||||
|
resolved_fw_rules.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
// 4. Audit reconciliation run
|
||||||
|
let _ = self
|
||||||
|
.state
|
||||||
|
.store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::ReconciliationRun,
|
||||||
|
"system",
|
||||||
|
Some("reconciliation"),
|
||||||
|
None,
|
||||||
|
Some(&format!("Reconciliation applied {} actions", details.len())),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
self.state.broadcast(SystemEvent::AuditEvent {
|
||||||
|
event_type: AuditEventType::ReconciliationRun,
|
||||||
|
message: Some(format!("Reconciliation applied {} actions", details.len())),
|
||||||
|
resource_type: Some("reconciliation".to_string()),
|
||||||
|
resource_id: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ReconciliationReport {
|
||||||
|
success: true,
|
||||||
|
executed_actions: details.len(),
|
||||||
|
details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background reconciliation loop running on a fixed interval.
|
||||||
|
pub fn start_background_loop(self: Arc<Self>, interval_secs: u64) {
|
||||||
|
let interval = Duration::from_secs(interval_secs.max(1));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
if let Err(e) = self.apply().await {
|
||||||
|
tracing::error!("Periodic reconciliation cycle failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,127 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>nx9-wg — Native WireGuard Appliance</title>
|
||||||
|
<meta name="description" content="Production Native Rust WireGuard Management Appliance for Linux">
|
||||||
|
<style>
|
||||||
|
/* STYLE_PLACEHOLDER */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app-layout">
|
||||||
|
<!-- Top Application Bar -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button id="menu-toggle-btn" class="menu-toggle-btn" aria-label="Toggle navigation drawer" onclick="toggleSidebar()">☰</button>
|
||||||
|
<div class="brand-logo">
|
||||||
|
<span class="brand-mark">NX9</span>
|
||||||
|
<span>nx9-wg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="ws-indicator" id="ws-indicator" title="WebSocket Live Event Stream">
|
||||||
|
<span id="ws-dot">●</span> <span id="ws-label" style="display:none;">Live</span>
|
||||||
|
</div>
|
||||||
|
<div class="system-status-indicator" id="global-status-badge">
|
||||||
|
<span>✓</span> <span>Operational</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-badge">
|
||||||
|
<span>👤</span> <span id="admin-user-label">admin</span>
|
||||||
|
</div>
|
||||||
|
<button class="theme-toggle-btn" onclick="toggleTheme()" title="Toggle Dark/Light theme" aria-label="Toggle theme">
|
||||||
|
<span id="theme-icon">🌙</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="navigateTo('settings')" title="Settings">⚙️</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="handleLogout()" title="Logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="main-container">
|
||||||
|
<!-- Mobile Backdrop -->
|
||||||
|
<div id="mobile-overlay" class="mobile-overlay" onclick="closeSidebar()"></div>
|
||||||
|
|
||||||
|
<!-- Navigation Sidebar -->
|
||||||
|
<nav id="sidebar" class="sidebar">
|
||||||
|
<!-- Primary Navigation -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title">Primary</div>
|
||||||
|
<a href="#dashboard" class="nav-link active" onclick="navigateTo('dashboard')">
|
||||||
|
<span class="nav-icon">📊</span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="#interfaces" class="nav-link" onclick="navigateTo('interfaces')">
|
||||||
|
<span class="nav-icon">🔌</span> Interfaces
|
||||||
|
</a>
|
||||||
|
<a href="#peers" class="nav-link" onclick="navigateTo('peers')">
|
||||||
|
<span class="nav-icon">📱</span> Peers
|
||||||
|
</a>
|
||||||
|
<a href="#networks" class="nav-link" onclick="navigateTo('networks')">
|
||||||
|
<span class="nav-icon">🌐</span> Networks
|
||||||
|
</a>
|
||||||
|
<a href="#routes" class="nav-link" onclick="navigateTo('routes')">
|
||||||
|
<span class="nav-icon">🛣️</span> Routes
|
||||||
|
</a>
|
||||||
|
<a href="#firewall" class="nav-link" onclick="navigateTo('firewall')">
|
||||||
|
<span class="nav-icon">🛡️</span> Firewall
|
||||||
|
</a>
|
||||||
|
<a href="#nat" class="nav-link" onclick="navigateTo('nat')">
|
||||||
|
<span class="nav-icon">🔀</span> NAT & Masquerade
|
||||||
|
</a>
|
||||||
|
<a href="#forwarding" class="nav-link" onclick="navigateTo('forwarding')">
|
||||||
|
<span class="nav-icon">⚡</span> IP Forwarding
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operations Navigation -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title">Operations</div>
|
||||||
|
<a href="#reconciliation" class="nav-link" onclick="navigateTo('reconciliation')">
|
||||||
|
<span class="nav-icon">🔄</span> Reconciliation
|
||||||
|
</a>
|
||||||
|
<a href="#diagnostics" class="nav-link" onclick="navigateTo('diagnostics')">
|
||||||
|
<span class="nav-icon">🩺</span> Diagnostics
|
||||||
|
</a>
|
||||||
|
<a href="#live-state" class="nav-link" onclick="navigateTo('live-state')">
|
||||||
|
<span class="nav-icon">📡</span> Live State
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Administration Navigation -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title">Administration</div>
|
||||||
|
<a href="#settings" class="nav-link" onclick="navigateTo('settings')">
|
||||||
|
<span class="nav-icon">⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
<a href="#backups" class="nav-link" onclick="navigateTo('backups')">
|
||||||
|
<span class="nav-icon">💾</span> Backups
|
||||||
|
</a>
|
||||||
|
<a href="#audit" class="nav-link" onclick="navigateTo('audit')">
|
||||||
|
<span class="nav-icon">📜</span> Audit Log
|
||||||
|
</a>
|
||||||
|
<a href="#administrator" class="nav-link" onclick="navigateTo('administrator')">
|
||||||
|
<span class="nav-icon">👤</span> Administrator
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content Container -->
|
||||||
|
<main id="main-content" class="content-wrapper">
|
||||||
|
<div id="page-container">
|
||||||
|
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
|
||||||
|
Loading nx9-wg native appliance...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Modal Container -->
|
||||||
|
<div id="modal-root" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Embedded Client Logic -->
|
||||||
|
<script>
|
||||||
|
/* SCRIPT_PLACEHOLDER */
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! Audit log query HTTP handler.
|
||||||
|
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use nx9_wg_core::types::audit::{AuditEvent, AuditEventType};
|
||||||
|
use nx9_wg_db::AuditFilter;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuditQueryParams {
|
||||||
|
pub event_type: Option<AuditEventType>,
|
||||||
|
pub resource_type: Option<String>,
|
||||||
|
pub resource_id: Option<String>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AuditQueryResponse {
|
||||||
|
pub total: i64,
|
||||||
|
pub limit: u32,
|
||||||
|
pub offset: u32,
|
||||||
|
pub events: Vec<AuditEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/audit
|
||||||
|
pub async fn list_audit_events_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<AuditQueryParams>,
|
||||||
|
) -> ApiResult<Json<AuditQueryResponse>> {
|
||||||
|
let limit = params.limit.unwrap_or(50).min(500);
|
||||||
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
let filter = AuditFilter {
|
||||||
|
event_type: params.event_type,
|
||||||
|
resource_type: params.resource_type,
|
||||||
|
resource_id: params.resource_id,
|
||||||
|
since: None,
|
||||||
|
until: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = state.store.count_audit_events(&filter).await?;
|
||||||
|
let events = state
|
||||||
|
.store
|
||||||
|
.list_audit_events(&filter, limit, offset)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(AuditQueryResponse {
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
events,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
//! Authentication and session management HTTP handlers.
|
||||||
|
|
||||||
|
use crate::auth::middleware::AuthenticatedAdmin;
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::http::header::SET_COOKIE;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub session_id: String,
|
||||||
|
pub expires_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChangePasswordRequest {
|
||||||
|
pub current_password: Option<String>,
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateTokenRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateTokenResponse {
|
||||||
|
pub token: nx9_wg_core::types::auth::ApiToken,
|
||||||
|
pub raw_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SessionResponse {
|
||||||
|
pub username: String,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub token_id: Option<String>,
|
||||||
|
pub totp_enabled: bool,
|
||||||
|
pub last_login_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GenericSuccess {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/auth/login
|
||||||
|
pub async fn login_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<LoginRequest>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
let session = state
|
||||||
|
.auth
|
||||||
|
.login(&payload.username, &payload.password, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let cookie_val = format!(
|
||||||
|
"nx9_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
|
||||||
|
session.id,
|
||||||
|
24 * 3600
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
SET_COOKIE,
|
||||||
|
cookie_val
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to build cookie header: {e}")))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp_body = Json(LoginResponse {
|
||||||
|
session_id: session.id,
|
||||||
|
expires_at: session.expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((headers, resp_body).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/auth/logout
|
||||||
|
pub async fn logout_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(auth_user): Extension<AuthenticatedAdmin>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
if let Some(ref session_id) = auth_user.session_id {
|
||||||
|
state.auth.logout(session_id, None).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie_val = "nx9_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
SET_COOKIE,
|
||||||
|
cookie_val
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to build cookie header: {e}")))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp_body = Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: "Logged out successfully".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((headers, resp_body).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/auth/session
|
||||||
|
pub async fn session_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(auth_user): Extension<AuthenticatedAdmin>,
|
||||||
|
) -> ApiResult<Json<SessionResponse>> {
|
||||||
|
let admin = state
|
||||||
|
.store
|
||||||
|
.get_admin()
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound("Administrator not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(SessionResponse {
|
||||||
|
username: auth_user.username,
|
||||||
|
session_id: auth_user.session_id,
|
||||||
|
token_id: auth_user.token_id,
|
||||||
|
totp_enabled: admin.totp_enabled,
|
||||||
|
last_login_at: admin.last_login_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/auth/password
|
||||||
|
pub async fn change_password_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_auth_user): Extension<AuthenticatedAdmin>,
|
||||||
|
Json(payload): Json<ChangePasswordRequest>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
// If current password was supplied, verify it first
|
||||||
|
if let Some(ref cur_pw) = payload.current_password {
|
||||||
|
let admin = state
|
||||||
|
.store
|
||||||
|
.get_admin()
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound("Administrator not found".to_string()))?;
|
||||||
|
if !nx9_wg_core::crypto::verify_password(cur_pw, &admin.password_hash)? {
|
||||||
|
return Err(ApiError::Unauthenticated(
|
||||||
|
"Current password does not match".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.auth
|
||||||
|
.change_password(&payload.new_password, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: "Password changed successfully. All active sessions invalidated.".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/auth/tokens
|
||||||
|
pub async fn create_token_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateTokenRequest>,
|
||||||
|
) -> ApiResult<Json<CreateTokenResponse>> {
|
||||||
|
let (token, raw_token) = state
|
||||||
|
.auth
|
||||||
|
.create_api_token(&payload.name, payload.expires_at, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(CreateTokenResponse { token, raw_token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/auth/tokens
|
||||||
|
pub async fn list_tokens_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<nx9_wg_core::types::auth::ApiToken>>> {
|
||||||
|
let tokens = state.store.list_tokens().await?;
|
||||||
|
Ok(Json(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/auth/tokens/{id}
|
||||||
|
pub async fn revoke_token_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.auth.revoke_api_token(&id, None).await?;
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("API token '{id}' revoked successfully"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//! Backup metadata, creation, verification, and restore HTTP handlers.
|
||||||
|
|
||||||
|
use crate::auth::middleware::AuthenticatedAdmin;
|
||||||
|
use crate::backup::BackupService;
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::routes::auth::GenericSuccess;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::backup::BackupMeta;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateBackupRecordRequest {
|
||||||
|
pub filename: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub checksum: String,
|
||||||
|
pub schema_version: String,
|
||||||
|
pub encrypted: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TriggerBackupRequest {
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/backups
|
||||||
|
pub async fn list_backups_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<BackupMeta>>> {
|
||||||
|
let list = state.store.list_backups().await?;
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/backups
|
||||||
|
pub async fn create_backup_record_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateBackupRecordRequest>,
|
||||||
|
) -> ApiResult<Json<BackupMeta>> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let meta = BackupMeta {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
filename: payload.filename,
|
||||||
|
size_bytes: payload.size_bytes,
|
||||||
|
checksum: payload.checksum,
|
||||||
|
schema_version: payload.schema_version,
|
||||||
|
encrypted: payload.encrypted.unwrap_or(false),
|
||||||
|
description: payload.description,
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.store.create_backup_meta(&meta).await?;
|
||||||
|
Ok(Json(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/backups/create
|
||||||
|
pub async fn trigger_backup_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||||
|
Json(payload): Json<TriggerBackupRequest>,
|
||||||
|
) -> ApiResult<Json<BackupMeta>> {
|
||||||
|
let backup_dir = PathBuf::from("backups");
|
||||||
|
let (meta, _path) = BackupService::create_backup(
|
||||||
|
&state.store,
|
||||||
|
&backup_dir,
|
||||||
|
payload.description.as_deref(),
|
||||||
|
&admin.username,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/backups/{id}
|
||||||
|
pub async fn get_backup_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<BackupMeta>> {
|
||||||
|
let meta = state
|
||||||
|
.store
|
||||||
|
.get_backup_meta(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Backup record '{id}' not found")))?;
|
||||||
|
Ok(Json(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/backups/{id}/download
|
||||||
|
pub async fn download_backup_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
let meta = state
|
||||||
|
.store
|
||||||
|
.get_backup_meta(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Backup record '{id}' not found")))?;
|
||||||
|
|
||||||
|
let backup_dir = PathBuf::from("backups");
|
||||||
|
let file_path = backup_dir.join(&meta.filename);
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Err(ApiError::NotFound(format!(
|
||||||
|
"Backup archive file '{}' not found on disk",
|
||||||
|
meta.filename
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&file_path)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to read backup file for download: {e}")))?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(CONTENT_TYPE, "application/octet-stream".parse().unwrap());
|
||||||
|
headers.insert(
|
||||||
|
CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}\"", meta.filename)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((headers, Body::from(bytes)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/backups/{id}
|
||||||
|
pub async fn delete_backup_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
let meta = state.store.get_backup_meta(id).await?;
|
||||||
|
if let Some(m) = meta {
|
||||||
|
let backup_dir = PathBuf::from("backups");
|
||||||
|
let file_path = backup_dir.join(&m.filename);
|
||||||
|
let _ = std::fs::remove_file(file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.store.delete_backup_meta(id).await?;
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Backup record '{id}' deleted"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/backups/{id}/restore
|
||||||
|
pub async fn restore_backup_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
let meta = state
|
||||||
|
.store
|
||||||
|
.get_backup_meta(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Backup record '{id}' not found")))?;
|
||||||
|
|
||||||
|
let backup_dir = PathBuf::from("backups");
|
||||||
|
let file_path = backup_dir.join(&meta.filename);
|
||||||
|
let active_db = PathBuf::from("nx9-wg.db");
|
||||||
|
let safety_dir = backup_dir.join("safety");
|
||||||
|
|
||||||
|
BackupService::restore_backup(
|
||||||
|
&state.store,
|
||||||
|
&file_path,
|
||||||
|
&active_db,
|
||||||
|
&safety_dir,
|
||||||
|
&admin.username,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!(
|
||||||
|
"Database successfully restored from backup '{}'",
|
||||||
|
meta.filename
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Client profiles REST API route handlers.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::profile_resolver::ClientProfileResolver;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use nx9_wg_core::types::client_profile::{
|
||||||
|
ClientProfile, ConnectionType, DeviceCategory, NatType, ResolvedClientProfile,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListProfilesQuery {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub device: Option<String>,
|
||||||
|
pub connection: Option<String>,
|
||||||
|
pub nat: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ResolveProfileRequest {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub device: Option<String>,
|
||||||
|
pub connection: Option<String>,
|
||||||
|
pub nat: Option<String>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DeviceCategoryInfo {
|
||||||
|
pub value: String,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/client-profiles
|
||||||
|
pub async fn list_client_profiles_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListProfilesQuery>,
|
||||||
|
) -> ApiResult<Json<Vec<ClientProfile>>> {
|
||||||
|
let provider = query.provider.as_deref();
|
||||||
|
let device = query
|
||||||
|
.device
|
||||||
|
.as_deref()
|
||||||
|
.map(DeviceCategory::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let connection = query
|
||||||
|
.connection
|
||||||
|
.as_deref()
|
||||||
|
.map(ConnectionType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let nat = query
|
||||||
|
.nat
|
||||||
|
.as_deref()
|
||||||
|
.map(NatType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let profiles =
|
||||||
|
if provider.is_some() || device.is_some() || connection.is_some() || nat.is_some() {
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.find_matching_client_profiles(provider, device, connection, nat)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
state.store.list_client_profiles().await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(profiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/client-profiles/providers
|
||||||
|
pub async fn list_providers_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<String>>> {
|
||||||
|
let providers = state.store.list_distinct_providers().await?;
|
||||||
|
Ok(Json(providers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/client-profiles/devices
|
||||||
|
pub async fn list_devices_handler() -> ApiResult<Json<Vec<DeviceCategoryInfo>>> {
|
||||||
|
let devices = vec![
|
||||||
|
DeviceCategoryInfo {
|
||||||
|
value: "android".to_string(),
|
||||||
|
label: "Android".to_string(),
|
||||||
|
},
|
||||||
|
DeviceCategoryInfo {
|
||||||
|
value: "ios".to_string(),
|
||||||
|
label: "iOS".to_string(),
|
||||||
|
},
|
||||||
|
DeviceCategoryInfo {
|
||||||
|
value: "linux".to_string(),
|
||||||
|
label: "Linux".to_string(),
|
||||||
|
},
|
||||||
|
DeviceCategoryInfo {
|
||||||
|
value: "windows".to_string(),
|
||||||
|
label: "Windows".to_string(),
|
||||||
|
},
|
||||||
|
DeviceCategoryInfo {
|
||||||
|
value: "macos".to_string(),
|
||||||
|
label: "macOS".to_string(),
|
||||||
|
},
|
||||||
|
DeviceCategoryInfo {
|
||||||
|
value: "other".to_string(),
|
||||||
|
label: "Other".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
Ok(Json(devices))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/client-profiles/{id}
|
||||||
|
pub async fn get_client_profile_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult<Json<ClientProfile>> {
|
||||||
|
let profile = state
|
||||||
|
.store
|
||||||
|
.get_client_profile(&id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("client profile '{id}' not found")))?;
|
||||||
|
|
||||||
|
Ok(Json(profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/client-profiles/resolve
|
||||||
|
pub async fn resolve_client_profile_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ResolveProfileRequest>,
|
||||||
|
) -> ApiResult<Json<ResolvedClientProfile>> {
|
||||||
|
let device = payload
|
||||||
|
.device
|
||||||
|
.as_deref()
|
||||||
|
.map(DeviceCategory::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let connection = payload
|
||||||
|
.connection
|
||||||
|
.as_deref()
|
||||||
|
.map(ConnectionType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let nat = payload
|
||||||
|
.nat
|
||||||
|
.as_deref()
|
||||||
|
.map(NatType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let resolved = ClientProfileResolver::resolve(
|
||||||
|
&state.store,
|
||||||
|
payload.provider.as_deref(),
|
||||||
|
device,
|
||||||
|
connection,
|
||||||
|
nat,
|
||||||
|
payload.mtu,
|
||||||
|
payload.profile.as_deref(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(resolved))
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Diagnostics HTTP handlers.
|
||||||
|
|
||||||
|
use crate::diagnostics::DiagnosticsService;
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::reconciliation::ReconciliationEngine;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use nx9_wg_core::types::diagnostics::{DiagnosticReport, DiagnosticSubsystem};
|
||||||
|
use nx9_wg_network::NativeLinuxNetworkEngine;
|
||||||
|
use nx9_wireguard::NativeLinuxWireGuardEngine;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DiagnosticQuery {
|
||||||
|
pub peer_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/diagnostics/all
|
||||||
|
pub async fn diagnose_all_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<DiagnosticReport>>> {
|
||||||
|
let wg_engine = Arc::new(NativeLinuxWireGuardEngine::new());
|
||||||
|
let net_engine = Arc::new(NativeLinuxNetworkEngine::new());
|
||||||
|
let reconciler = Arc::new(ReconciliationEngine::new(
|
||||||
|
state.clone(),
|
||||||
|
wg_engine.clone(),
|
||||||
|
net_engine.clone(),
|
||||||
|
));
|
||||||
|
let service = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
|
||||||
|
let reports = service.diagnose_all().await?;
|
||||||
|
Ok(Json(reports))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/diagnostics/{subsystem}
|
||||||
|
pub async fn diagnose_subsystem_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(subsystem_str): Path<String>,
|
||||||
|
Query(query): Query<DiagnosticQuery>,
|
||||||
|
) -> ApiResult<Json<Vec<DiagnosticReport>>> {
|
||||||
|
let subsystem = DiagnosticSubsystem::from_str(&subsystem_str)
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let wg_engine = Arc::new(NativeLinuxWireGuardEngine::new());
|
||||||
|
let net_engine = Arc::new(NativeLinuxNetworkEngine::new());
|
||||||
|
let reconciler = Arc::new(ReconciliationEngine::new(
|
||||||
|
state.clone(),
|
||||||
|
wg_engine.clone(),
|
||||||
|
net_engine.clone(),
|
||||||
|
));
|
||||||
|
let service = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
|
||||||
|
let reports = service.run_diagnostic(subsystem, query.peer_id).await?;
|
||||||
|
Ok(Json(reports))
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Firewall Rule HTTP handlers.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::routes::auth::GenericSuccess;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::firewall::{
|
||||||
|
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||||
|
};
|
||||||
|
use nx9_wg_core::validation::validate_port_spec;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListFirewallQuery {
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
pub peer_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateFirewallRuleRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
pub peer_id: Option<Uuid>,
|
||||||
|
pub direction: Option<FirewallDirection>,
|
||||||
|
pub action: Option<FirewallAction>,
|
||||||
|
pub protocol: Option<FirewallProtocol>,
|
||||||
|
pub source: Option<String>,
|
||||||
|
pub destination: Option<String>,
|
||||||
|
pub source_port: Option<u16>,
|
||||||
|
pub destination_port: Option<u16>,
|
||||||
|
pub port_range: Option<String>,
|
||||||
|
pub priority: Option<i32>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateFirewallRuleRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
pub peer_id: Option<Uuid>,
|
||||||
|
pub direction: Option<FirewallDirection>,
|
||||||
|
pub action: Option<FirewallAction>,
|
||||||
|
pub protocol: Option<FirewallProtocol>,
|
||||||
|
pub source: Option<String>,
|
||||||
|
pub destination: Option<String>,
|
||||||
|
pub source_port: Option<u16>,
|
||||||
|
pub destination_port: Option<u16>,
|
||||||
|
pub port_range: Option<String>,
|
||||||
|
pub priority: Option<i32>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/firewall/rules
|
||||||
|
pub async fn list_firewall_rules_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListFirewallQuery>,
|
||||||
|
) -> ApiResult<Json<Vec<FirewallRule>>> {
|
||||||
|
let list = if let Some(peer_id) = query.peer_id {
|
||||||
|
state.store.list_firewall_rules_for_peer(peer_id).await?
|
||||||
|
} else if let Some(iface_id) = query.interface_id {
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.list_firewall_rules_for_interface(iface_id)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
state.store.list_firewall_rules().await?
|
||||||
|
};
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/firewall/rules
|
||||||
|
pub async fn create_firewall_rule_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateFirewallRuleRequest>,
|
||||||
|
) -> ApiResult<Json<FirewallRule>> {
|
||||||
|
if payload.name.trim().is_empty() {
|
||||||
|
return Err(ApiError::Validation(
|
||||||
|
"Rule name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref pr) = payload.port_range {
|
||||||
|
validate_port_spec(pr).map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let rule = FirewallRule {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: payload.name,
|
||||||
|
interface_id: payload.interface_id,
|
||||||
|
peer_id: payload.peer_id,
|
||||||
|
direction: payload.direction.unwrap_or(FirewallDirection::In),
|
||||||
|
action: payload.action.unwrap_or(FirewallAction::Accept),
|
||||||
|
protocol: payload.protocol.unwrap_or(FirewallProtocol::Any),
|
||||||
|
source: payload.source,
|
||||||
|
destination: payload.destination,
|
||||||
|
source_port: payload.source_port,
|
||||||
|
destination_port: payload.destination_port,
|
||||||
|
port_range: payload.port_range,
|
||||||
|
priority: payload.priority.unwrap_or(100),
|
||||||
|
enabled: true,
|
||||||
|
description: payload.description,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.store.create_firewall_rule(&rule).await?;
|
||||||
|
Ok(Json(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/firewall/rules/{id}
|
||||||
|
pub async fn get_firewall_rule_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<FirewallRule>> {
|
||||||
|
let rule = state
|
||||||
|
.store
|
||||||
|
.get_firewall_rule(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Firewall rule '{id}' not found")))?;
|
||||||
|
Ok(Json(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/firewall/rules/{id}
|
||||||
|
pub async fn update_firewall_rule_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateFirewallRuleRequest>,
|
||||||
|
) -> ApiResult<Json<FirewallRule>> {
|
||||||
|
let mut rule = state
|
||||||
|
.store
|
||||||
|
.get_firewall_rule(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Firewall rule '{id}' not found")))?;
|
||||||
|
|
||||||
|
if let Some(ref name) = payload.name {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(ApiError::Validation(
|
||||||
|
"Rule name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rule.name = name.clone();
|
||||||
|
}
|
||||||
|
if payload.interface_id.is_some() {
|
||||||
|
rule.interface_id = payload.interface_id;
|
||||||
|
}
|
||||||
|
if payload.peer_id.is_some() {
|
||||||
|
rule.peer_id = payload.peer_id;
|
||||||
|
}
|
||||||
|
if let Some(dir) = payload.direction {
|
||||||
|
rule.direction = dir;
|
||||||
|
}
|
||||||
|
if let Some(act) = payload.action {
|
||||||
|
rule.action = act;
|
||||||
|
}
|
||||||
|
if let Some(proto) = payload.protocol {
|
||||||
|
rule.protocol = proto;
|
||||||
|
}
|
||||||
|
if payload.source.is_some() {
|
||||||
|
rule.source = payload.source;
|
||||||
|
}
|
||||||
|
if payload.destination.is_some() {
|
||||||
|
rule.destination = payload.destination;
|
||||||
|
}
|
||||||
|
if payload.source_port.is_some() {
|
||||||
|
rule.source_port = payload.source_port;
|
||||||
|
}
|
||||||
|
if payload.destination_port.is_some() {
|
||||||
|
rule.destination_port = payload.destination_port;
|
||||||
|
}
|
||||||
|
if let Some(ref pr) = payload.port_range {
|
||||||
|
validate_port_spec(pr).map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
rule.port_range = Some(pr.clone());
|
||||||
|
}
|
||||||
|
if let Some(prio) = payload.priority {
|
||||||
|
rule.priority = prio;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = payload.enabled {
|
||||||
|
rule.enabled = enabled;
|
||||||
|
}
|
||||||
|
if payload.description.is_some() {
|
||||||
|
rule.description = payload.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.store.update_firewall_rule(&rule).await?;
|
||||||
|
Ok(Json(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/firewall/rules/{id}
|
||||||
|
pub async fn delete_firewall_rule_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.delete_firewall_rule(id).await?;
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Firewall rule '{id}' deleted"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/firewall/rules/{id}/enable
|
||||||
|
pub async fn enable_firewall_rule_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.set_firewall_rule_enabled(id, true).await?;
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Firewall rule '{id}' enabled"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/firewall/rules/{id}/disable
|
||||||
|
pub async fn disable_firewall_rule_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.set_firewall_rule_enabled(id, false).await?;
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Firewall rule '{id}' disabled"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! WireGuard Interface HTTP handlers.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::routes::auth::GenericSuccess;
|
||||||
|
use crate::state::{AppState, SystemEvent};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::crypto::generate_keypair;
|
||||||
|
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
|
||||||
|
use nx9_wg_core::validation::{
|
||||||
|
validate_cidr, validate_interface_name, validate_listen_port, validate_mtu,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateInterfaceRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub listen_port: Option<u16>,
|
||||||
|
pub address_v4: String,
|
||||||
|
pub address_v6: Option<String>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub private_key: Option<String>,
|
||||||
|
pub public_key: Option<String>,
|
||||||
|
pub pre_up: Option<String>,
|
||||||
|
pub post_up: Option<String>,
|
||||||
|
pub pre_down: Option<String>,
|
||||||
|
pub post_down: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateInterfaceRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub listen_port: Option<u16>,
|
||||||
|
pub address_v4: Option<String>,
|
||||||
|
pub address_v6: Option<String>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub pre_up: Option<String>,
|
||||||
|
pub post_up: Option<String>,
|
||||||
|
pub pre_down: Option<String>,
|
||||||
|
pub post_down: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct InterfaceStatusResponse {
|
||||||
|
pub interface: Interface,
|
||||||
|
pub peer_count: usize,
|
||||||
|
pub active_peer_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/interfaces
|
||||||
|
pub async fn list_interfaces_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<Interface>>> {
|
||||||
|
let list = state.store.list_interfaces().await?;
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/interfaces
|
||||||
|
pub async fn create_interface_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateInterfaceRequest>,
|
||||||
|
) -> ApiResult<Json<Interface>> {
|
||||||
|
validate_interface_name(&payload.name)?;
|
||||||
|
let address_v4 = validate_cidr(&payload.address_v4)?;
|
||||||
|
let address_v6 = match payload.address_v6.as_deref() {
|
||||||
|
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let listen_port = match payload.listen_port {
|
||||||
|
Some(p) => validate_listen_port(p)?,
|
||||||
|
None => 51820,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(m) = payload.mtu {
|
||||||
|
validate_mtu(m)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (priv_k, pub_k) = match (payload.private_key, payload.public_key) {
|
||||||
|
(Some(priv_s), Some(pub_s)) => (
|
||||||
|
WireGuardPrivateKey::new(priv_s),
|
||||||
|
WireGuardPublicKey::new(pub_s),
|
||||||
|
),
|
||||||
|
_ => generate_keypair(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let iface = Interface {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: payload.name,
|
||||||
|
private_key: priv_k,
|
||||||
|
public_key: pub_k,
|
||||||
|
listen_port,
|
||||||
|
address_v4,
|
||||||
|
address_v6,
|
||||||
|
mtu: payload.mtu,
|
||||||
|
dns: payload.dns,
|
||||||
|
enabled: true,
|
||||||
|
pre_up: payload.pre_up,
|
||||||
|
post_up: payload.post_up,
|
||||||
|
pre_down: payload.pre_down,
|
||||||
|
post_down: payload.post_down,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.store.create_interface(&iface).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::InterfaceChanged {
|
||||||
|
id: iface.id.to_string(),
|
||||||
|
action: "created".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(iface))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/interfaces/{id}
|
||||||
|
pub async fn get_interface_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Interface>> {
|
||||||
|
let iface = state
|
||||||
|
.store
|
||||||
|
.get_interface(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
||||||
|
Ok(Json(iface))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/interfaces/{id}
|
||||||
|
pub async fn update_interface_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateInterfaceRequest>,
|
||||||
|
) -> ApiResult<Json<Interface>> {
|
||||||
|
let mut iface = state
|
||||||
|
.store
|
||||||
|
.get_interface(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
||||||
|
|
||||||
|
if let Some(ref name) = payload.name {
|
||||||
|
validate_interface_name(name)?;
|
||||||
|
iface.name = name.clone();
|
||||||
|
}
|
||||||
|
if let Some(port) = payload.listen_port {
|
||||||
|
validate_listen_port(port)?;
|
||||||
|
iface.listen_port = port;
|
||||||
|
}
|
||||||
|
if let Some(ref v4) = payload.address_v4 {
|
||||||
|
iface.address_v4 = validate_cidr(v4)?;
|
||||||
|
}
|
||||||
|
if let Some(ref v6) = payload.address_v6 {
|
||||||
|
iface.address_v6 = Some(validate_cidr(v6)?);
|
||||||
|
}
|
||||||
|
if let Some(m) = payload.mtu {
|
||||||
|
validate_mtu(m)?;
|
||||||
|
iface.mtu = Some(m);
|
||||||
|
}
|
||||||
|
if let Some(ref dns) = payload.dns {
|
||||||
|
iface.dns = Some(dns.clone());
|
||||||
|
}
|
||||||
|
if payload.pre_up.is_some() {
|
||||||
|
iface.pre_up = payload.pre_up;
|
||||||
|
}
|
||||||
|
if payload.post_up.is_some() {
|
||||||
|
iface.post_up = payload.post_up;
|
||||||
|
}
|
||||||
|
if payload.pre_down.is_some() {
|
||||||
|
iface.pre_down = payload.pre_down;
|
||||||
|
}
|
||||||
|
if payload.post_down.is_some() {
|
||||||
|
iface.post_down = payload.post_down;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.store.update_interface(&iface).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::InterfaceChanged {
|
||||||
|
id: iface.id.to_string(),
|
||||||
|
action: "updated".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(iface))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/interfaces/{id}
|
||||||
|
pub async fn delete_interface_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.delete_interface(id).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::InterfaceChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "deleted".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Interface '{id}' and all associated peers deleted"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/interfaces/{id}/enable
|
||||||
|
pub async fn enable_interface_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.set_interface_enabled(id, true).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::InterfaceChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "enabled".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Interface '{id}' enabled"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/interfaces/{id}/disable
|
||||||
|
pub async fn disable_interface_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.set_interface_enabled(id, false).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::InterfaceChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "disabled".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Interface '{id}' disabled"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/interfaces/{id}/status
|
||||||
|
pub async fn interface_status_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<InterfaceStatusResponse>> {
|
||||||
|
let iface = state
|
||||||
|
.store
|
||||||
|
.get_interface(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
||||||
|
|
||||||
|
let peers = state.store.list_peers_for_interface(id).await?;
|
||||||
|
let active_count = peers
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.state == nx9_wg_core::types::wireguard::PeerState::Active)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
Ok(Json(InterfaceStatusResponse {
|
||||||
|
interface: iface,
|
||||||
|
peer_count: peers.len(),
|
||||||
|
active_peer_count: active_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//! Router assembly and route module declarations.
|
||||||
|
|
||||||
|
pub mod audit;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod backups;
|
||||||
|
pub mod client_profiles;
|
||||||
|
pub mod diagnostics;
|
||||||
|
pub mod firewall;
|
||||||
|
pub mod interfaces;
|
||||||
|
pub mod networks;
|
||||||
|
pub mod peers;
|
||||||
|
pub mod reconcile;
|
||||||
|
pub mod routing;
|
||||||
|
pub mod system;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod ws;
|
||||||
|
|
||||||
|
use crate::auth::middleware::require_auth;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
|
use axum::routing::{delete, get, post, put};
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
/// Build the complete Axum API Router with all public, protected, and UI routes.
|
||||||
|
pub fn build_api_router(state: AppState) -> Router {
|
||||||
|
// 1. Protected routes (require authenticated admin via session or token)
|
||||||
|
let protected_router = Router::new()
|
||||||
|
// Auth management
|
||||||
|
.route("/auth/logout", post(auth::logout_handler))
|
||||||
|
.route("/auth/session", get(auth::session_handler))
|
||||||
|
.route("/auth/password", post(auth::change_password_handler))
|
||||||
|
.route("/auth/tokens", post(auth::create_token_handler))
|
||||||
|
.route("/auth/tokens", get(auth::list_tokens_handler))
|
||||||
|
.route("/auth/tokens/{id}", delete(auth::revoke_token_handler))
|
||||||
|
// System
|
||||||
|
.route("/system", get(system::system_overview_handler))
|
||||||
|
.route("/system/settings", get(system::list_settings_handler))
|
||||||
|
.route("/system/settings", put(system::upsert_setting_handler))
|
||||||
|
// Interfaces
|
||||||
|
.route("/interfaces", get(interfaces::list_interfaces_handler))
|
||||||
|
.route("/interfaces", post(interfaces::create_interface_handler))
|
||||||
|
.route("/interfaces/{id}", get(interfaces::get_interface_handler))
|
||||||
|
.route(
|
||||||
|
"/interfaces/{id}",
|
||||||
|
put(interfaces::update_interface_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/interfaces/{id}",
|
||||||
|
delete(interfaces::delete_interface_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/interfaces/{id}/enable",
|
||||||
|
post(interfaces::enable_interface_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/interfaces/{id}/disable",
|
||||||
|
post(interfaces::disable_interface_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/interfaces/{id}/status",
|
||||||
|
get(interfaces::interface_status_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/interfaces/{id}/peers",
|
||||||
|
get(peers::list_peers_for_interface_handler),
|
||||||
|
)
|
||||||
|
.route("/interfaces/{id}/peers", post(peers::create_peer_handler))
|
||||||
|
// Peers
|
||||||
|
.route("/peers/{id}", get(peers::get_peer_handler))
|
||||||
|
.route("/peers/{id}", put(peers::update_peer_handler))
|
||||||
|
.route("/peers/{id}", delete(peers::delete_peer_handler))
|
||||||
|
.route("/peers/{id}/enable", post(peers::enable_peer_handler))
|
||||||
|
.route("/peers/{id}/disable", post(peers::disable_peer_handler))
|
||||||
|
.route("/peers/{id}/revoke", post(peers::revoke_peer_handler))
|
||||||
|
.route("/peers/{id}/expire", post(peers::expire_peer_handler))
|
||||||
|
.route(
|
||||||
|
"/peers/{id}/lifecycle",
|
||||||
|
get(peers::get_peer_lifecycle_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/peers/{id}/config",
|
||||||
|
get(peers::download_peer_config_handler),
|
||||||
|
)
|
||||||
|
.route("/peers/{id}/qr", get(peers::get_peer_qr_handler))
|
||||||
|
// Networks
|
||||||
|
.route("/networks", get(networks::list_networks_handler))
|
||||||
|
.route("/networks", post(networks::create_network_handler))
|
||||||
|
.route("/networks/{id}", get(networks::get_network_handler))
|
||||||
|
.route("/networks/{id}", put(networks::update_network_handler))
|
||||||
|
.route("/networks/{id}", delete(networks::delete_network_handler))
|
||||||
|
.route(
|
||||||
|
"/networks/{id}/available",
|
||||||
|
get(networks::list_available_ips_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/networks/{id}/allocations",
|
||||||
|
get(networks::list_allocations_handler),
|
||||||
|
)
|
||||||
|
// Routes
|
||||||
|
.route("/routes", get(routing::list_routes_handler))
|
||||||
|
.route("/routes", post(routing::create_route_handler))
|
||||||
|
.route("/routes/{id}", get(routing::get_route_handler))
|
||||||
|
.route("/routes/{id}", put(routing::update_route_handler))
|
||||||
|
.route("/routes/{id}", delete(routing::delete_route_handler))
|
||||||
|
// Firewall
|
||||||
|
.route(
|
||||||
|
"/firewall/rules",
|
||||||
|
get(firewall::list_firewall_rules_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/firewall/rules",
|
||||||
|
post(firewall::create_firewall_rule_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/firewall/rules/{id}",
|
||||||
|
get(firewall::get_firewall_rule_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/firewall/rules/{id}",
|
||||||
|
put(firewall::update_firewall_rule_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/firewall/rules/{id}",
|
||||||
|
delete(firewall::delete_firewall_rule_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/firewall/rules/{id}/enable",
|
||||||
|
post(firewall::enable_firewall_rule_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/firewall/rules/{id}/disable",
|
||||||
|
post(firewall::disable_firewall_rule_handler),
|
||||||
|
)
|
||||||
|
// Diagnostics
|
||||||
|
.route("/diagnostics/all", get(diagnostics::diagnose_all_handler))
|
||||||
|
.route(
|
||||||
|
"/diagnostics/{subsystem}",
|
||||||
|
get(diagnostics::diagnose_subsystem_handler),
|
||||||
|
)
|
||||||
|
// Client Profiles
|
||||||
|
.route(
|
||||||
|
"/client-profiles",
|
||||||
|
get(client_profiles::list_client_profiles_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/client-profiles/providers",
|
||||||
|
get(client_profiles::list_providers_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/client-profiles/devices",
|
||||||
|
get(client_profiles::list_devices_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/client-profiles/{id}",
|
||||||
|
get(client_profiles::get_client_profile_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/client-profiles/resolve",
|
||||||
|
post(client_profiles::resolve_client_profile_handler),
|
||||||
|
)
|
||||||
|
// Audit
|
||||||
|
.route("/audit", get(audit::list_audit_events_handler))
|
||||||
|
// Backups
|
||||||
|
.route("/backups", get(backups::list_backups_handler))
|
||||||
|
.route("/backups", post(backups::create_backup_record_handler))
|
||||||
|
.route("/backups/create", post(backups::trigger_backup_handler))
|
||||||
|
.route("/backups/{id}", get(backups::get_backup_handler))
|
||||||
|
.route(
|
||||||
|
"/backups/{id}/download",
|
||||||
|
get(backups::download_backup_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/backups/{id}/restore",
|
||||||
|
post(backups::restore_backup_handler),
|
||||||
|
)
|
||||||
|
.route("/backups/{id}", delete(backups::delete_backup_handler))
|
||||||
|
// Reconcile
|
||||||
|
.route(
|
||||||
|
"/reconcile/plan",
|
||||||
|
get(reconcile::get_reconciliation_plan_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/reconcile/apply",
|
||||||
|
post(reconcile::apply_reconciliation_handler),
|
||||||
|
)
|
||||||
|
// Attach authentication middleware
|
||||||
|
.route_layer(from_fn_with_state(state.auth.clone(), require_auth));
|
||||||
|
|
||||||
|
// 2. Public API routes (no authentication required)
|
||||||
|
let public_router = Router::new()
|
||||||
|
.route("/auth/login", post(auth::login_handler))
|
||||||
|
.route("/system/health", get(system::health_handler))
|
||||||
|
.route("/system/version", get(system::version_handler))
|
||||||
|
.route("/ws", get(ws::ws_handler));
|
||||||
|
|
||||||
|
// 3. Web UI routes and assets
|
||||||
|
let ui_router = Router::new()
|
||||||
|
.route("/", get(ui::index_handler))
|
||||||
|
.route("/ui", get(ui::index_handler))
|
||||||
|
.route("/assets/style.css", get(ui::stylesheet_handler));
|
||||||
|
|
||||||
|
// 4. Nest all under root and /api/v1 and attach global middleware
|
||||||
|
ui_router
|
||||||
|
.nest("/api/v1", public_router.merge(protected_router))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! Network subnet HTTP handlers.
|
||||||
|
|
||||||
|
use crate::allocator::{IpAllocator, NetworkAllocation};
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::routes::auth::GenericSuccess;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::network::Network;
|
||||||
|
use nx9_wg_core::validation::validate_cidr;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateNetworkRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub cidr: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateNetworkRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub cidr: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AvailableIpsQuery {
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/networks
|
||||||
|
pub async fn list_networks_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<Network>>> {
|
||||||
|
let list = state.store.list_networks().await?;
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/networks
|
||||||
|
pub async fn create_network_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateNetworkRequest>,
|
||||||
|
) -> ApiResult<Json<Network>> {
|
||||||
|
if payload.name.trim().is_empty() {
|
||||||
|
return Err(ApiError::Validation(
|
||||||
|
"Network name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cidr = validate_cidr(&payload.cidr)?;
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let net = Network {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: payload.name,
|
||||||
|
cidr,
|
||||||
|
enabled: true,
|
||||||
|
description: payload.description,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.store.create_network(&net).await?;
|
||||||
|
Ok(Json(net))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/networks/{id}
|
||||||
|
pub async fn get_network_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Network>> {
|
||||||
|
let net = state
|
||||||
|
.store
|
||||||
|
.get_network(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
|
||||||
|
Ok(Json(net))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/networks/{id}/available
|
||||||
|
pub async fn list_available_ips_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<AvailableIpsQuery>,
|
||||||
|
) -> ApiResult<Json<Vec<IpAddr>>> {
|
||||||
|
let net = state
|
||||||
|
.store
|
||||||
|
.get_network(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
|
||||||
|
|
||||||
|
let interface = match query.interface_id {
|
||||||
|
Some(iface_id) => state.store.get_interface(iface_id).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let limit = query.limit.unwrap_or(10).min(100);
|
||||||
|
let available =
|
||||||
|
IpAllocator::list_available_ips(&state.store, &net, interface.as_ref(), limit).await?;
|
||||||
|
|
||||||
|
Ok(Json(available))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/networks/{id}/allocations
|
||||||
|
pub async fn list_allocations_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Vec<NetworkAllocation>>> {
|
||||||
|
let net = state
|
||||||
|
.store
|
||||||
|
.get_network(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
|
||||||
|
|
||||||
|
let allocations = IpAllocator::list_allocations(&state.store, &net).await?;
|
||||||
|
Ok(Json(allocations))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/networks/{id}
|
||||||
|
pub async fn update_network_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateNetworkRequest>,
|
||||||
|
) -> ApiResult<Json<Network>> {
|
||||||
|
let mut net = state
|
||||||
|
.store
|
||||||
|
.get_network(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Network '{id}' not found")))?;
|
||||||
|
|
||||||
|
if let Some(ref name) = payload.name {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(ApiError::Validation(
|
||||||
|
"Network name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
net.name = name.clone();
|
||||||
|
}
|
||||||
|
if let Some(ref cidr_str) = payload.cidr {
|
||||||
|
net.cidr = validate_cidr(cidr_str)?;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = payload.enabled {
|
||||||
|
net.enabled = enabled;
|
||||||
|
}
|
||||||
|
if payload.description.is_some() {
|
||||||
|
net.description = payload.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.store.update_network(&net).await?;
|
||||||
|
Ok(Json(net))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/networks/{id}
|
||||||
|
pub async fn delete_network_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.delete_network(id).await?;
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Network '{id}' deleted"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
//! WireGuard Peer HTTP handlers.
|
||||||
|
|
||||||
|
use crate::allocator::IpAllocator;
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::routes::auth::GenericSuccess;
|
||||||
|
use crate::state::{AppState, SystemEvent};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||||
|
use nx9_wg_core::types::network::Network;
|
||||||
|
use nx9_wg_core::types::wireguard::{
|
||||||
|
Peer, PeerProfile, PeerState, PeerType, WireGuardPresharedKey, WireGuardPrivateKey,
|
||||||
|
WireGuardPublicKey,
|
||||||
|
};
|
||||||
|
use nx9_wg_core::validation::{validate_cidr, validate_mtu, validate_peer_name};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreatePeerRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub peer_type: Option<PeerType>,
|
||||||
|
pub profile: Option<PeerProfile>,
|
||||||
|
pub network_id: Option<Uuid>,
|
||||||
|
pub public_key: Option<String>,
|
||||||
|
pub private_key: Option<String>,
|
||||||
|
pub preshared_key: Option<String>,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub allowed_ips: Option<String>,
|
||||||
|
pub server_allowed_ips: Option<String>,
|
||||||
|
pub address_v4: Option<String>,
|
||||||
|
pub address_v6: Option<String>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub persistent_keepalive: Option<u16>,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdatePeerRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub peer_type: Option<PeerType>,
|
||||||
|
pub profile: Option<PeerProfile>,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub allowed_ips: Option<String>,
|
||||||
|
pub server_allowed_ips: Option<String>,
|
||||||
|
pub address_v4: Option<String>,
|
||||||
|
pub address_v6: Option<String>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub persistent_keepalive: Option<u16>,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PeerLifecycleResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub state: PeerState,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
pub is_expired: bool,
|
||||||
|
pub last_handshake_at: Option<NaiveDateTime>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/interfaces/{id}/peers
|
||||||
|
pub async fn list_peers_for_interface_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(interface_id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Vec<Peer>>> {
|
||||||
|
let peers = state.store.list_peers_for_interface(interface_id).await?;
|
||||||
|
Ok(Json(peers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/interfaces/{id}/peers
|
||||||
|
pub async fn create_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(interface_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<CreatePeerRequest>,
|
||||||
|
) -> ApiResult<Json<Peer>> {
|
||||||
|
// Verify interface exists
|
||||||
|
let interface = state
|
||||||
|
.store
|
||||||
|
.get_interface(interface_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{interface_id}' not found")))?;
|
||||||
|
|
||||||
|
validate_peer_name(&payload.name)?;
|
||||||
|
|
||||||
|
let mut address_v4 = match payload.address_v4.as_deref() {
|
||||||
|
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let address_v6 = match payload.address_v6.as_deref() {
|
||||||
|
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If address_v4 was not explicitly provided, automatically allocate it
|
||||||
|
if address_v4.is_none() {
|
||||||
|
let net = match payload.network_id {
|
||||||
|
Some(net_id) => state
|
||||||
|
.store
|
||||||
|
.get_network(net_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Network '{net_id}' not found")))?,
|
||||||
|
None => Network {
|
||||||
|
id: Uuid::nil(),
|
||||||
|
name: format!("{}-subnet", interface.name),
|
||||||
|
cidr: interface.address_v4,
|
||||||
|
enabled: true,
|
||||||
|
description: None,
|
||||||
|
created_at: Utc::now().naive_utc(),
|
||||||
|
updated_at: Utc::now().naive_utc(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let allocated =
|
||||||
|
IpAllocator::allocate_next_ip(&state.store, &net, Some(&interface), None).await?;
|
||||||
|
address_v4 = Some(allocated);
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed_ips = match payload.allowed_ips {
|
||||||
|
Some(s) if !s.trim().is_empty() => s,
|
||||||
|
_ => {
|
||||||
|
if let Some(v4) = address_v4 {
|
||||||
|
v4.to_string()
|
||||||
|
} else {
|
||||||
|
"0.0.0.0/0".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(m) = payload.mtu {
|
||||||
|
validate_mtu(m)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (priv_k, pub_k) = match (payload.private_key, payload.public_key) {
|
||||||
|
(Some(priv_s), Some(pub_s)) => (
|
||||||
|
Some(WireGuardPrivateKey::new(priv_s)),
|
||||||
|
WireGuardPublicKey::new(pub_s),
|
||||||
|
),
|
||||||
|
(None, Some(pub_s)) => (None, WireGuardPublicKey::new(pub_s)),
|
||||||
|
_ => {
|
||||||
|
let (priv_k, pub_k) = generate_keypair();
|
||||||
|
(Some(priv_k), pub_k)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let preshared_key = match payload.preshared_key {
|
||||||
|
Some(psk) if !psk.trim().is_empty() => Some(WireGuardPresharedKey::new(psk)),
|
||||||
|
_ => Some(generate_preshared_key()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let peer = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id,
|
||||||
|
name: payload.name,
|
||||||
|
peer_type: payload.peer_type.unwrap_or(PeerType::RoadWarrior),
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: pub_k,
|
||||||
|
private_key: priv_k,
|
||||||
|
preshared_key,
|
||||||
|
endpoint: payload.endpoint,
|
||||||
|
allowed_ips,
|
||||||
|
server_allowed_ips: payload.server_allowed_ips,
|
||||||
|
address_v4,
|
||||||
|
address_v6,
|
||||||
|
dns: payload.dns,
|
||||||
|
mtu: payload.mtu,
|
||||||
|
persistent_keepalive: payload.persistent_keepalive.or(Some(25)),
|
||||||
|
profile: payload.profile.unwrap_or(PeerProfile::FullTunnel),
|
||||||
|
expires_at: payload.expires_at,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.store.create_peer(&peer).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: peer.id.to_string(),
|
||||||
|
action: "created".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/peers/{id}
|
||||||
|
pub async fn get_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Peer>> {
|
||||||
|
let peer = state
|
||||||
|
.store
|
||||||
|
.get_peer(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
||||||
|
Ok(Json(peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/peers/{id}
|
||||||
|
pub async fn update_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdatePeerRequest>,
|
||||||
|
) -> ApiResult<Json<Peer>> {
|
||||||
|
let mut peer = state
|
||||||
|
.store
|
||||||
|
.get_peer(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
||||||
|
|
||||||
|
if let Some(ref name) = payload.name {
|
||||||
|
validate_peer_name(name)?;
|
||||||
|
peer.name = name.clone();
|
||||||
|
}
|
||||||
|
if let Some(pt) = payload.peer_type {
|
||||||
|
peer.peer_type = pt;
|
||||||
|
}
|
||||||
|
if let Some(prof) = payload.profile {
|
||||||
|
peer.profile = prof;
|
||||||
|
}
|
||||||
|
if payload.endpoint.is_some() {
|
||||||
|
peer.endpoint = payload.endpoint;
|
||||||
|
}
|
||||||
|
if let Some(ref ips) = payload.allowed_ips {
|
||||||
|
peer.allowed_ips = ips.clone();
|
||||||
|
}
|
||||||
|
if payload.server_allowed_ips.is_some() {
|
||||||
|
peer.server_allowed_ips = payload.server_allowed_ips;
|
||||||
|
}
|
||||||
|
if let Some(ref v4) = payload.address_v4 {
|
||||||
|
peer.address_v4 = Some(validate_cidr(v4)?);
|
||||||
|
}
|
||||||
|
if let Some(ref v6) = payload.address_v6 {
|
||||||
|
peer.address_v6 = Some(validate_cidr(v6)?);
|
||||||
|
}
|
||||||
|
if payload.dns.is_some() {
|
||||||
|
peer.dns = payload.dns;
|
||||||
|
}
|
||||||
|
if let Some(m) = payload.mtu {
|
||||||
|
validate_mtu(m)?;
|
||||||
|
peer.mtu = Some(m);
|
||||||
|
}
|
||||||
|
if payload.persistent_keepalive.is_some() {
|
||||||
|
peer.persistent_keepalive = payload.persistent_keepalive;
|
||||||
|
}
|
||||||
|
if payload.expires_at.is_some() {
|
||||||
|
peer.expires_at = payload.expires_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.store.update_peer(&peer).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "updated".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/peers/{id}
|
||||||
|
pub async fn delete_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.delete_peer(id).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "deleted".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Peer '{id}' deleted"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/peers/{id}/enable
|
||||||
|
pub async fn enable_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.set_peer_state(id, PeerState::Active).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "enabled".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Peer '{id}' enabled"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/peers/{id}/disable
|
||||||
|
pub async fn disable_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.set_peer_state(id, PeerState::Disabled).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "disabled".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Peer '{id}' disabled"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/peers/{id}/revoke
|
||||||
|
pub async fn revoke_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.set_peer_state(id, PeerState::Revoked).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "revoked".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Peer '{id}' revoked"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/peers/{id}/expire
|
||||||
|
pub async fn expire_peer_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.mark_peer_expired(id).await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::PeerChanged {
|
||||||
|
id: id.to_string(),
|
||||||
|
action: "expired".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Peer '{id}' marked as expired"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/peers/{id}/lifecycle
|
||||||
|
pub async fn get_peer_lifecycle_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<PeerLifecycleResponse>> {
|
||||||
|
let peer = state
|
||||||
|
.store
|
||||||
|
.get_peer(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let is_expired =
|
||||||
|
peer.state == PeerState::Expired || peer.expires_at.map(|exp| exp <= now).unwrap_or(false);
|
||||||
|
|
||||||
|
Ok(Json(PeerLifecycleResponse {
|
||||||
|
id: peer.id,
|
||||||
|
name: peer.name,
|
||||||
|
state: peer.state,
|
||||||
|
expires_at: peer.expires_at,
|
||||||
|
is_expired,
|
||||||
|
last_handshake_at: peer.last_handshake_at,
|
||||||
|
created_at: peer.created_at,
|
||||||
|
updated_at: peer.updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct ClientProfileQuery {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub device: Option<String>,
|
||||||
|
pub connection: Option<String>,
|
||||||
|
pub nat: Option<String>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct PeerQrResponse {
|
||||||
|
pub peer_id: Uuid,
|
||||||
|
pub svg: String,
|
||||||
|
pub data_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/peers/{id}/config
|
||||||
|
pub async fn download_peer_config_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<ClientProfileQuery>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
let peer = state
|
||||||
|
.store
|
||||||
|
.get_peer(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
||||||
|
|
||||||
|
let interface = state
|
||||||
|
.store
|
||||||
|
.get_interface(peer.interface_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
|
||||||
|
|
||||||
|
let host = state
|
||||||
|
.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()
|
||||||
|
|| query.device.is_some()
|
||||||
|
|| query.connection.is_some()
|
||||||
|
|| query.nat.is_some()
|
||||||
|
|| query.mtu.is_some()
|
||||||
|
|| query.profile.is_some()
|
||||||
|
{
|
||||||
|
let device = query
|
||||||
|
.device
|
||||||
|
.as_deref()
|
||||||
|
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let connection = query
|
||||||
|
.connection
|
||||||
|
.as_deref()
|
||||||
|
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let nat = query
|
||||||
|
.nat
|
||||||
|
.as_deref()
|
||||||
|
.map(nx9_wg_core::types::client_profile::NatType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Some(
|
||||||
|
crate::profile_resolver::ClientProfileResolver::resolve(
|
||||||
|
&state.store,
|
||||||
|
query.provider.as_deref(),
|
||||||
|
device,
|
||||||
|
connection,
|
||||||
|
nat,
|
||||||
|
query.mtu,
|
||||||
|
query.profile.as_deref(),
|
||||||
|
interface.mtu,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_str = nx9_wireguard::ClientConfigBuilder::build_with_profile(
|
||||||
|
&peer,
|
||||||
|
&interface,
|
||||||
|
&host,
|
||||||
|
resolved_profile.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to build peer configuration: {e}")))?;
|
||||||
|
|
||||||
|
let mut headers = axum::http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
"text/plain; charset=utf-8".parse().unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}.conf\"", peer.name)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((headers, axum::body::Body::from(config_str)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/peers/{id}/qr
|
||||||
|
pub async fn get_peer_qr_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<ClientProfileQuery>,
|
||||||
|
) -> ApiResult<Json<PeerQrResponse>> {
|
||||||
|
let peer = state
|
||||||
|
.store
|
||||||
|
.get_peer(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Peer '{id}' not found")))?;
|
||||||
|
|
||||||
|
let interface = state
|
||||||
|
.store
|
||||||
|
.get_interface(peer.interface_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound("Associated interface not found".to_string()))?;
|
||||||
|
|
||||||
|
let host = state
|
||||||
|
.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()
|
||||||
|
|| query.device.is_some()
|
||||||
|
|| query.connection.is_some()
|
||||||
|
|| query.nat.is_some()
|
||||||
|
|| query.mtu.is_some()
|
||||||
|
|| query.profile.is_some()
|
||||||
|
{
|
||||||
|
let device = query
|
||||||
|
.device
|
||||||
|
.as_deref()
|
||||||
|
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let connection = query
|
||||||
|
.connection
|
||||||
|
.as_deref()
|
||||||
|
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
let nat = query
|
||||||
|
.nat
|
||||||
|
.as_deref()
|
||||||
|
.map(nx9_wg_core::types::client_profile::NatType::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Some(
|
||||||
|
crate::profile_resolver::ClientProfileResolver::resolve(
|
||||||
|
&state.store,
|
||||||
|
query.provider.as_deref(),
|
||||||
|
device,
|
||||||
|
connection,
|
||||||
|
nat,
|
||||||
|
query.mtu,
|
||||||
|
query.profile.as_deref(),
|
||||||
|
interface.mtu,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_str = nx9_wireguard::ClientConfigBuilder::build_with_profile(
|
||||||
|
&peer,
|
||||||
|
&interface,
|
||||||
|
&host,
|
||||||
|
resolved_profile.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to build peer configuration: {e}")))?;
|
||||||
|
|
||||||
|
let svg = nx9_wireguard::generate_qr_svg(&config_str)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to generate SVG QR code: {e}")))?;
|
||||||
|
|
||||||
|
let data_url = nx9_wireguard::generate_qr_data_url(&config_str)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to generate PNG QR code: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(PeerQrResponse {
|
||||||
|
peer_id: peer.id,
|
||||||
|
svg,
|
||||||
|
data_url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Reconciliation REST handlers.
|
||||||
|
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::reconciliation::{ReconciliationEngine, ReconciliationPlan, ReconciliationReport};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use nx9_wg_network::SimulatedNetworkEngine;
|
||||||
|
use nx9_wireguard::SimulatedWireGuardEngine;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// GET /api/v1/reconcile/plan
|
||||||
|
pub async fn get_reconciliation_plan_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<ReconciliationPlan>> {
|
||||||
|
let wg = Arc::new(SimulatedWireGuardEngine::new());
|
||||||
|
let net = Arc::new(SimulatedNetworkEngine::new());
|
||||||
|
let engine = ReconciliationEngine::new(state, wg, net);
|
||||||
|
|
||||||
|
let plan = engine.plan().await?;
|
||||||
|
Ok(Json(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/reconcile/apply
|
||||||
|
pub async fn apply_reconciliation_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<ReconciliationReport>> {
|
||||||
|
let wg = Arc::new(SimulatedWireGuardEngine::new());
|
||||||
|
let net = Arc::new(SimulatedNetworkEngine::new());
|
||||||
|
let engine = ReconciliationEngine::new(state, wg, net);
|
||||||
|
|
||||||
|
let report = engine.apply().await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Route HTTP handlers.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::routes::auth::GenericSuccess;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::network::Route;
|
||||||
|
use nx9_wg_core::validation::{validate_cidr, validate_ip};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateRouteRequest {
|
||||||
|
pub network_id: Option<Uuid>,
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
pub destination: String,
|
||||||
|
pub gateway: Option<String>,
|
||||||
|
pub metric: Option<u32>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateRouteRequest {
|
||||||
|
pub network_id: Option<Uuid>,
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
pub destination: Option<String>,
|
||||||
|
pub gateway: Option<String>,
|
||||||
|
pub metric: Option<u32>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/routes
|
||||||
|
pub async fn list_routes_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<Route>>> {
|
||||||
|
let list = state.store.list_routes().await?;
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/routes
|
||||||
|
pub async fn create_route_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateRouteRequest>,
|
||||||
|
) -> ApiResult<Json<Route>> {
|
||||||
|
let destination = validate_cidr(&payload.destination)?;
|
||||||
|
let gateway = match payload.gateway.as_deref() {
|
||||||
|
Some(s) if !s.trim().is_empty() => Some(validate_ip(s)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let route = Route {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
network_id: payload.network_id,
|
||||||
|
interface_id: payload.interface_id,
|
||||||
|
destination,
|
||||||
|
gateway,
|
||||||
|
interface_name: None,
|
||||||
|
metric: payload.metric,
|
||||||
|
enabled: true,
|
||||||
|
description: payload.description,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.store.create_route(&route).await?;
|
||||||
|
Ok(Json(route))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/routes/{id}
|
||||||
|
pub async fn get_route_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Route>> {
|
||||||
|
let route = state
|
||||||
|
.store
|
||||||
|
.get_route(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Route '{id}' not found")))?;
|
||||||
|
Ok(Json(route))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/routes/{id}
|
||||||
|
pub async fn update_route_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateRouteRequest>,
|
||||||
|
) -> ApiResult<Json<Route>> {
|
||||||
|
let mut route = state
|
||||||
|
.store
|
||||||
|
.get_route(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Route '{id}' not found")))?;
|
||||||
|
|
||||||
|
if payload.network_id.is_some() {
|
||||||
|
route.network_id = payload.network_id;
|
||||||
|
}
|
||||||
|
if payload.interface_id.is_some() {
|
||||||
|
route.interface_id = payload.interface_id;
|
||||||
|
}
|
||||||
|
if let Some(ref dest) = payload.destination {
|
||||||
|
route.destination = validate_cidr(dest)?;
|
||||||
|
}
|
||||||
|
if let Some(ref gw) = payload.gateway {
|
||||||
|
route.gateway = Some(validate_ip(gw)?);
|
||||||
|
}
|
||||||
|
if payload.metric.is_some() {
|
||||||
|
route.metric = payload.metric;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = payload.enabled {
|
||||||
|
route.enabled = enabled;
|
||||||
|
}
|
||||||
|
if payload.description.is_some() {
|
||||||
|
route.description = payload.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.store.update_route(&route).await?;
|
||||||
|
Ok(Json(route))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/routes/{id}
|
||||||
|
pub async fn delete_route_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
state.store.delete_route(id).await?;
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Route '{id}' deleted"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! System overview, health, version, and settings HTTP handlers.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::routes::auth::GenericSuccess;
|
||||||
|
use crate::state::{AppState, SystemEvent};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use nx9_wg_core::types::settings::Setting;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SystemOverview {
|
||||||
|
pub version: &'static str,
|
||||||
|
pub admin_initialized: bool,
|
||||||
|
pub interface_count: usize,
|
||||||
|
pub peer_count: usize,
|
||||||
|
pub network_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HealthStatus {
|
||||||
|
pub status: &'static str,
|
||||||
|
pub database: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct VersionInfo {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub version: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpsertSettingRequest {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub is_secret: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/system
|
||||||
|
pub async fn system_overview_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<SystemOverview>> {
|
||||||
|
let admin_initialized = state.store.admin_exists().await?;
|
||||||
|
let interfaces = state.store.list_interfaces().await?;
|
||||||
|
let peers = state.store.list_all_peers().await?;
|
||||||
|
let networks = state.store.list_networks().await?;
|
||||||
|
|
||||||
|
Ok(Json(SystemOverview {
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
admin_initialized,
|
||||||
|
interface_count: interfaces.len(),
|
||||||
|
peer_count: peers.len(),
|
||||||
|
network_count: networks.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/system/health
|
||||||
|
pub async fn health_handler(State(state): State<AppState>) -> ApiResult<Json<HealthStatus>> {
|
||||||
|
// Ping SQLite store
|
||||||
|
let _ = state
|
||||||
|
.store
|
||||||
|
.admin_exists()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Unavailable(format!("Database health check failed: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(HealthStatus {
|
||||||
|
status: "healthy",
|
||||||
|
database: "connected",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/system/version
|
||||||
|
pub async fn version_handler() -> Json<VersionInfo> {
|
||||||
|
Json(VersionInfo {
|
||||||
|
name: "nx9-wg",
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/system/settings
|
||||||
|
pub async fn list_settings_handler(State(state): State<AppState>) -> ApiResult<Json<Vec<Setting>>> {
|
||||||
|
let mut settings = state.store.list_settings().await?;
|
||||||
|
// Redact secret values from generic listing
|
||||||
|
for s in &mut settings {
|
||||||
|
if s.is_secret {
|
||||||
|
s.value = "[REDACTED]".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/system/settings
|
||||||
|
pub async fn upsert_setting_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<UpsertSettingRequest>,
|
||||||
|
) -> ApiResult<Json<GenericSuccess>> {
|
||||||
|
if payload.key.trim().is_empty() {
|
||||||
|
return Err(ApiError::Validation(
|
||||||
|
"Setting key cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_secret = payload.is_secret.unwrap_or(false);
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.set_setting(&payload.key, &payload.value, is_secret)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
state.broadcast(SystemEvent::SettingsChanged {
|
||||||
|
key: payload.key.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(GenericSuccess {
|
||||||
|
success: true,
|
||||||
|
message: format!("Setting '{}' saved successfully", payload.key),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! Embedded Web UI Single Page Application and asset handlers.
|
||||||
|
|
||||||
|
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use nx9_wg_ui::generate_stylesheet;
|
||||||
|
|
||||||
|
/// Serves the single-page application root HTML document.
|
||||||
|
pub async fn index_handler() -> impl IntoResponse {
|
||||||
|
let html = render_spa_html();
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
Html(html),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves the production CSS stylesheet.
|
||||||
|
pub async fn stylesheet_handler() -> Response {
|
||||||
|
let css = generate_stylesheet();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/css; charset=utf-8"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=3600"),
|
||||||
|
);
|
||||||
|
(StatusCode::OK, headers, css).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the complete HTML5 document shell for nx9-wg.
|
||||||
|
pub fn render_spa_html() -> String {
|
||||||
|
let raw_html = include_str!("app_index.html");
|
||||||
|
let css = generate_stylesheet();
|
||||||
|
let script = include_str!("app_client_js.js");
|
||||||
|
|
||||||
|
raw_html
|
||||||
|
.replace("/* STYLE_PLACEHOLDER */", &css)
|
||||||
|
.replace("/* SCRIPT_PLACEHOLDER */", script)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//! Real-time WebSocket event streaming.
|
||||||
|
|
||||||
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct WsAuthQuery {
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub session: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/ws
|
||||||
|
pub async fn ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<WsAuthQuery>,
|
||||||
|
) -> ApiResult<impl IntoResponse> {
|
||||||
|
// Authenticate WebSocket connection via query parameters
|
||||||
|
let authenticated = if let Some(ref raw_token) = query.token {
|
||||||
|
state.auth.authenticate_token(raw_token).await.is_ok()
|
||||||
|
} else if let Some(ref session_id) = query.session {
|
||||||
|
state.auth.authenticate_session(session_id).await.is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if !authenticated {
|
||||||
|
return Err(ApiError::Unauthenticated(
|
||||||
|
"WebSocket authentication required. Supply ?token=... or ?session=...".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ws.on_upgrade(move |socket| handle_socket(socket, state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||||
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
let mut rx = state.event_tx.subscribe();
|
||||||
|
|
||||||
|
// Spawn background task to stream broadcast events to client
|
||||||
|
let mut send_task = tokio::spawn(async move {
|
||||||
|
while let Ok(event) = rx.recv().await {
|
||||||
|
if let Ok(json) = serde_json::to_string(&event) {
|
||||||
|
let msg = Message::Text(json.into());
|
||||||
|
if sender.send(msg).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client receive loop to handle close/ping/pong
|
||||||
|
let mut recv_task = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(msg)) = receiver.next().await {
|
||||||
|
if let Message::Close(_) = msg {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If either task exits, abort the other
|
||||||
|
tokio::select! {
|
||||||
|
_ = (&mut send_task) => recv_task.abort(),
|
||||||
|
_ = (&mut recv_task) => send_task.abort(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Application state and WebSocket event bus.
|
||||||
|
|
||||||
|
use crate::auth::service::AuthService;
|
||||||
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
/// Real-time system event broadcasted over WebSocket to connected clients.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "payload")]
|
||||||
|
pub enum SystemEvent {
|
||||||
|
AuditEvent {
|
||||||
|
event_type: AuditEventType,
|
||||||
|
message: Option<String>,
|
||||||
|
resource_type: Option<String>,
|
||||||
|
resource_id: Option<String>,
|
||||||
|
},
|
||||||
|
InterfaceChanged {
|
||||||
|
id: String,
|
||||||
|
action: String,
|
||||||
|
},
|
||||||
|
PeerChanged {
|
||||||
|
id: String,
|
||||||
|
action: String,
|
||||||
|
},
|
||||||
|
PeerHandshake {
|
||||||
|
id: String,
|
||||||
|
last_handshake_at: String,
|
||||||
|
},
|
||||||
|
SettingsChanged {
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared application state across HTTP handlers and WebSocket streams.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub store: Store,
|
||||||
|
pub auth: AuthService,
|
||||||
|
pub event_tx: broadcast::Sender<SystemEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Create a new AppState instance.
|
||||||
|
pub fn new(store: Store) -> Self {
|
||||||
|
let (event_tx, _) = broadcast::channel(256);
|
||||||
|
let auth = AuthService::new(store.clone());
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
auth,
|
||||||
|
event_tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast an event to all connected WebSocket subscribers.
|
||||||
|
pub fn broadcast(&self, event: SystemEvent) {
|
||||||
|
let _ = self.event_tx.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//! Integration tests for Phase 2: Authentication, Admin Bootstrap, Rate Limiting, and Security.
|
||||||
|
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use nx9_wg_api::auth::{AuthService, BootstrapOptions, ResolvedSource, bootstrap_admin};
|
||||||
|
use nx9_wg_core::config::AppConfig;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_bootstrap_all_sources_and_rejection() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
|
||||||
|
// 1. Bootstrap with explicit CLI password
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let opts = BootstrapOptions {
|
||||||
|
admin_username: Some("custom_admin".to_string()),
|
||||||
|
cli_password: Some("SecurePassword123!".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let res = bootstrap_admin(&store, &config, &opts)
|
||||||
|
.await
|
||||||
|
.expect("bootstrap cli");
|
||||||
|
assert_eq!(res.source, ResolvedSource::CliArgument);
|
||||||
|
assert_eq!(res.admin.username, "custom_admin");
|
||||||
|
|
||||||
|
// Re-bootstrap must fail
|
||||||
|
let re_bootstrap = bootstrap_admin(&store, &config, &opts).await;
|
||||||
|
assert!(re_bootstrap.is_err(), "re-bootstrap must be rejected");
|
||||||
|
|
||||||
|
// 2. Bootstrap from password file
|
||||||
|
let store2 = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store2.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let tmp_file = NamedTempFile::new().expect("temp file");
|
||||||
|
std::fs::write(tmp_file.path(), "FileSecretPass999!\n").expect("write secret");
|
||||||
|
|
||||||
|
let opts2 = BootstrapOptions {
|
||||||
|
password_file: Some(tmp_file.path().to_str().unwrap().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let res2 = bootstrap_admin(&store2, &config, &opts2)
|
||||||
|
.await
|
||||||
|
.expect("bootstrap file");
|
||||||
|
assert_eq!(res2.source, ResolvedSource::PasswordFile);
|
||||||
|
assert_eq!(res2.admin.username, "admin");
|
||||||
|
|
||||||
|
// 3. Bootstrap from generated password
|
||||||
|
let store3 = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store3.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let gen_file = NamedTempFile::new().expect("gen file");
|
||||||
|
let opts3 = BootstrapOptions {
|
||||||
|
generate_password: true,
|
||||||
|
write_password_file: Some(gen_file.path().to_str().unwrap().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let res3 = bootstrap_admin(&store3, &config, &opts3)
|
||||||
|
.await
|
||||||
|
.expect("bootstrap gen");
|
||||||
|
assert_eq!(res3.source, ResolvedSource::Generated);
|
||||||
|
assert!(res3.generated_plaintext.is_some());
|
||||||
|
let gen_pw = res3.generated_plaintext.unwrap();
|
||||||
|
let written = std::fs::read_to_string(gen_file.path()).expect("read gen");
|
||||||
|
assert_eq!(written, gen_pw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_service_login_and_rate_limiting() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Successful login
|
||||||
|
let session = auth
|
||||||
|
.login(
|
||||||
|
"admin",
|
||||||
|
"AdminSecret123!",
|
||||||
|
Some("192.168.1.50"),
|
||||||
|
Some("TestBrowser/1.0"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("successful login");
|
||||||
|
assert_eq!(session.admin_id, 1);
|
||||||
|
assert_eq!(session.ip_address.as_deref(), Some("192.168.1.50"));
|
||||||
|
|
||||||
|
// Authenticate with valid session
|
||||||
|
let authenticated = auth
|
||||||
|
.authenticate_session(&session.id)
|
||||||
|
.await
|
||||||
|
.expect("authenticate session");
|
||||||
|
assert_eq!(authenticated.id, session.id);
|
||||||
|
|
||||||
|
// Wrong password login fails
|
||||||
|
let fail = auth
|
||||||
|
.login("admin", "WrongPass123!", Some("192.168.1.50"), None)
|
||||||
|
.await;
|
||||||
|
assert!(fail.is_err(), "wrong password must fail");
|
||||||
|
|
||||||
|
// Test rate-limit lockout after 5 failed attempts from same IP
|
||||||
|
let attacker_ip = "10.0.0.99";
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = auth
|
||||||
|
.login("admin", "WrongPass123!", Some(attacker_ip), None)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th attempt must be rejected with rate limit lockout even with correct password
|
||||||
|
let lockout = auth
|
||||||
|
.login("admin", "AdminSecret123!", Some(attacker_ip), None)
|
||||||
|
.await;
|
||||||
|
assert!(lockout.is_err());
|
||||||
|
let err_msg = lockout.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("rate limited") || err_msg.contains("Too many failed"),
|
||||||
|
"error should indicate rate limit lockout: {err_msg}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Login from another IP should still succeed
|
||||||
|
let other_ip_login = auth
|
||||||
|
.login("admin", "AdminSecret123!", Some("192.168.1.60"), None)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
other_ip_login.is_ok(),
|
||||||
|
"different IP must not be locked out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_service_password_change_invalidates_sessions() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let config = AppConfig::default();
|
||||||
|
let opts = BootstrapOptions {
|
||||||
|
cli_password: Some("OriginalPassword123!".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
bootstrap_admin(&store, &config, &opts)
|
||||||
|
.await
|
||||||
|
.expect("bootstrap");
|
||||||
|
|
||||||
|
let auth = AuthService::new(store.clone());
|
||||||
|
|
||||||
|
// Create two active sessions
|
||||||
|
let s1 = auth
|
||||||
|
.login("admin", "OriginalPassword123!", Some("1.1.1.1"), None)
|
||||||
|
.await
|
||||||
|
.expect("login 1");
|
||||||
|
let s2 = auth
|
||||||
|
.login("admin", "OriginalPassword123!", Some("2.2.2.2"), None)
|
||||||
|
.await
|
||||||
|
.expect("login 2");
|
||||||
|
|
||||||
|
assert!(auth.authenticate_session(&s1.id).await.is_ok());
|
||||||
|
assert!(auth.authenticate_session(&s2.id).await.is_ok());
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
auth.change_password("NewRotatedPassword456!", Some("1.1.1.1"))
|
||||||
|
.await
|
||||||
|
.expect("change password");
|
||||||
|
|
||||||
|
// Both previous sessions must now be rejected
|
||||||
|
assert!(
|
||||||
|
auth.authenticate_session(&s1.id).await.is_err(),
|
||||||
|
"s1 must be invalidated"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
auth.authenticate_session(&s2.id).await.is_err(),
|
||||||
|
"s2 must be invalidated"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Old password must fail; new password must succeed
|
||||||
|
assert!(
|
||||||
|
auth.login("admin", "OriginalPassword123!", None, None)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let new_login = auth
|
||||||
|
.login("admin", "NewRotatedPassword456!", None, None)
|
||||||
|
.await
|
||||||
|
.expect("new login");
|
||||||
|
assert!(auth.authenticate_session(&new_login.id).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_service_api_tokens() {
|
||||||
|
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 API token
|
||||||
|
let (token_meta, raw_token) = auth
|
||||||
|
.create_api_token(
|
||||||
|
"Terraform Runner",
|
||||||
|
Some(Utc::now().naive_utc() + Duration::days(7)),
|
||||||
|
Some("10.0.0.1"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create token");
|
||||||
|
assert!(raw_token.starts_with("nx9_"));
|
||||||
|
assert_eq!(token_meta.name, "Terraform Runner");
|
||||||
|
|
||||||
|
// Authenticate with raw token
|
||||||
|
let authenticated = auth
|
||||||
|
.authenticate_token(&raw_token)
|
||||||
|
.await
|
||||||
|
.expect("authenticate token");
|
||||||
|
assert_eq!(authenticated.id, token_meta.id);
|
||||||
|
|
||||||
|
// Revoke token
|
||||||
|
auth.revoke_api_token(&token_meta.id, Some("10.0.0.1"))
|
||||||
|
.await
|
||||||
|
.expect("revoke");
|
||||||
|
|
||||||
|
// Authenticating revoked token must fail
|
||||||
|
assert!(
|
||||||
|
auth.authenticate_token(&raw_token).await.is_err(),
|
||||||
|
"revoked token must fail authentication"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//! Integration test suite for Backup and Restore engine.
|
||||||
|
|
||||||
|
use nx9_wg_api::backup::BackupService;
|
||||||
|
use nx9_wg_core::types::network::Network;
|
||||||
|
use nx9_wg_core::validation::validate_cidr;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_backup_create_verify_and_restore() {
|
||||||
|
let dir = tempdir().expect("create temp dir");
|
||||||
|
let active_db_path = dir.path().join("active.db");
|
||||||
|
let backup_dir = dir.path().join("backups");
|
||||||
|
let safety_dir = dir.path().join("safety");
|
||||||
|
|
||||||
|
let store = Store::connect(&active_db_path.to_string_lossy())
|
||||||
|
.await
|
||||||
|
.expect("connect to db");
|
||||||
|
store.migrate().await.expect("run migrations");
|
||||||
|
|
||||||
|
// Insert test record
|
||||||
|
let net = Network {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "test_lan".to_string(),
|
||||||
|
cidr: validate_cidr("10.50.0.0/24").unwrap(),
|
||||||
|
enabled: true,
|
||||||
|
description: Some("LAN subnet".to_string()),
|
||||||
|
created_at: chrono::Utc::now().naive_utc(),
|
||||||
|
updated_at: chrono::Utc::now().naive_utc(),
|
||||||
|
};
|
||||||
|
store.create_network(&net).await.expect("create network");
|
||||||
|
|
||||||
|
// Create Backup
|
||||||
|
let (meta, backup_file) = BackupService::create_backup(
|
||||||
|
&store,
|
||||||
|
&backup_dir,
|
||||||
|
Some("Test backup snapshot"),
|
||||||
|
"test_admin",
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create backup");
|
||||||
|
|
||||||
|
assert!(backup_file.exists());
|
||||||
|
assert!(meta.size_bytes > 0);
|
||||||
|
assert!(!meta.checksum.is_empty());
|
||||||
|
|
||||||
|
// Verify Backup
|
||||||
|
let is_valid =
|
||||||
|
BackupService::verify_backup(&backup_file, Some(&meta.checksum)).expect("verify backup");
|
||||||
|
assert!(is_valid, "Backup file should be valid SQLite archive");
|
||||||
|
|
||||||
|
// List backups from store
|
||||||
|
let backups = store.list_backups().await.expect("list backups");
|
||||||
|
assert_eq!(backups.len(), 1);
|
||||||
|
assert_eq!(backups[0].id, meta.id);
|
||||||
|
|
||||||
|
// Modify active DB by adding another network
|
||||||
|
let net2 = Network {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "temporary_lan".to_string(),
|
||||||
|
cidr: validate_cidr("10.99.0.0/24").unwrap(),
|
||||||
|
enabled: true,
|
||||||
|
description: None,
|
||||||
|
created_at: chrono::Utc::now().naive_utc(),
|
||||||
|
updated_at: chrono::Utc::now().naive_utc(),
|
||||||
|
};
|
||||||
|
store.create_network(&net2).await.expect("create net2");
|
||||||
|
assert_eq!(store.list_networks().await.unwrap().len(), 2);
|
||||||
|
|
||||||
|
// Restore Backup
|
||||||
|
BackupService::restore_backup(
|
||||||
|
&store,
|
||||||
|
&backup_file,
|
||||||
|
&active_db_path,
|
||||||
|
&safety_dir,
|
||||||
|
"test_admin",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("restore backup");
|
||||||
|
|
||||||
|
// Reopen store to verify restored content
|
||||||
|
let restored_store = Store::connect(&active_db_path.to_string_lossy())
|
||||||
|
.await
|
||||||
|
.expect("reconnect store");
|
||||||
|
let restored_networks = restored_store.list_networks().await.expect("list restored");
|
||||||
|
assert_eq!(restored_networks.len(), 1);
|
||||||
|
assert_eq!(restored_networks[0].name, "test_lan");
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Integration tests for Client Profiles REST API endpoints and resolver.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_api::state::AppState;
|
||||||
|
use nx9_wg_core::crypto::generate_keypair;
|
||||||
|
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, ResolvedClientProfile};
|
||||||
|
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
||||||
|
let store = Store::connect_in_memory().await.unwrap();
|
||||||
|
store.migrate().await.unwrap();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
let hash = nx9_wg_core::crypto::hash_password("adminpassword123").unwrap();
|
||||||
|
store.create_admin("admin", &hash).await.unwrap();
|
||||||
|
|
||||||
|
// Create session token
|
||||||
|
let session = nx9_wg_core::types::auth::Session {
|
||||||
|
id: "test-session-id-12345".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.0.0.1/24").unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: Some(1420),
|
||||||
|
dns: Some("1.1.1.1".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: None,
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
store.create_interface(&interface).await.unwrap();
|
||||||
|
|
||||||
|
let peer = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: interface.id,
|
||||||
|
name: "test-mobile-peer".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: "10.0.0.2/32".to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
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);
|
||||||
|
let app = nx9_wg_api::routes::build_api_router(state.clone());
|
||||||
|
|
||||||
|
(app, state, session.id, interface, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_client_profiles_endpoints() {
|
||||||
|
let (app, _state, session_id, _iface, peer) = setup_test_app().await;
|
||||||
|
|
||||||
|
// 1. List client profiles
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/v1/client-profiles")
|
||||||
|
.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 profiles: Vec<ClientProfile> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(profiles.len() >= 10);
|
||||||
|
|
||||||
|
// 2. List distinct providers
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/v1/client-profiles/providers")
|
||||||
|
.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 providers: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(providers.contains(&"tmobile".to_string()));
|
||||||
|
assert!(providers.contains(&"starlink".to_string()));
|
||||||
|
|
||||||
|
// 3. List device categories
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/v1/client-profiles/devices")
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// 4. Resolve client profile via POST
|
||||||
|
let resolve_body = serde_json::json!({
|
||||||
|
"connection": "mobile",
|
||||||
|
"device": "android",
|
||||||
|
"nat": "cgnat"
|
||||||
|
});
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/client-profiles/resolve")
|
||||||
|
.header("Cookie", format!("nx9_session={session_id}"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&resolve_body).unwrap()))
|
||||||
|
.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 resolved: ResolvedClientProfile = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(resolved.mtu, 1280);
|
||||||
|
assert_eq!(resolved.connection_type, ConnectionType::Mobile);
|
||||||
|
|
||||||
|
// 5. Download peer .conf with mobile profile parameters
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/api/v1/peers/{}/config?connection=mobile&device=android",
|
||||||
|
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("MTU = 1280"));
|
||||||
|
assert!(conf_str.contains("PersistentKeepalive = 25"));
|
||||||
|
|
||||||
|
// 6. Get QR code with CGNAT profile parameters
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/peers/{}/qr?nat=cgnat", 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 qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Integration test suite for Reconciliation Engine.
|
||||||
|
|
||||||
|
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||||
|
use nx9_wg_api::state::AppState;
|
||||||
|
use nx9_wg_core::crypto::generate_keypair;
|
||||||
|
use nx9_wg_core::types::wireguard::Interface;
|
||||||
|
use nx9_wg_core::validation::validate_cidr;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use nx9_wg_network::SimulatedNetworkEngine;
|
||||||
|
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reconciliation_engine_drift_detection_and_apply() {
|
||||||
|
let dir = tempdir().expect("create temp dir");
|
||||||
|
let db_path = dir.path().join("reconcile.db");
|
||||||
|
let store = Store::connect(&db_path.to_string_lossy())
|
||||||
|
.await
|
||||||
|
.expect("connect to db");
|
||||||
|
store.migrate().await.expect("run migrations");
|
||||||
|
|
||||||
|
let state = AppState::new(store.clone());
|
||||||
|
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||||
|
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||||
|
let reconciler = ReconciliationEngine::new(state, wg_engine.clone(), net_engine.clone());
|
||||||
|
|
||||||
|
// 1. Create desired interface in SQLite
|
||||||
|
let (priv_key, pub_key) = generate_keypair();
|
||||||
|
let iface = Interface {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "wg0".to_string(),
|
||||||
|
private_key: priv_key,
|
||||||
|
public_key: pub_key,
|
||||||
|
listen_port: 51820,
|
||||||
|
address_v4: validate_cidr("10.0.0.1/24").unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: Some(1420),
|
||||||
|
dns: None,
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: None,
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: chrono::Utc::now().naive_utc(),
|
||||||
|
updated_at: chrono::Utc::now().naive_utc(),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.create_interface(&iface)
|
||||||
|
.await
|
||||||
|
.expect("create interface");
|
||||||
|
|
||||||
|
// 2. Compute plan: should detect missing wg0 in kernel
|
||||||
|
let plan = reconciler.plan().await.expect("compute plan");
|
||||||
|
assert!(plan.has_drift);
|
||||||
|
assert_eq!(plan.interface_changes, 1);
|
||||||
|
assert!(!plan.actions.is_empty());
|
||||||
|
|
||||||
|
// 3. Apply reconciliation
|
||||||
|
let report = reconciler.apply().await.expect("apply plan");
|
||||||
|
assert!(report.success);
|
||||||
|
assert!(report.executed_actions > 0);
|
||||||
|
|
||||||
|
// 4. Verify live WireGuard interface state
|
||||||
|
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap();
|
||||||
|
assert!(live_stats.is_some());
|
||||||
|
let stats = live_stats.unwrap();
|
||||||
|
assert_eq!(stats.name, "wg0");
|
||||||
|
assert_eq!(stats.listen_port, 51820);
|
||||||
|
|
||||||
|
// 5. Verify audit event was logged
|
||||||
|
let audits = store
|
||||||
|
.list_audit_events(&nx9_wg_db::AuditFilter::default(), 10, 0)
|
||||||
|
.await
|
||||||
|
.expect("list audits");
|
||||||
|
assert!(!audits.is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
use axum::body::{Body, to_bytes};
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
|
||||||
|
use nx9_wg_api::routes::build_api_router;
|
||||||
|
use nx9_wg_api::state::AppState;
|
||||||
|
use nx9_wg_core::config::AppConfig;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn setup_test_app() -> (axum::Router, String) {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect in-memory");
|
||||||
|
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 state = AppState::new(store);
|
||||||
|
let app = build_api_router(state.clone());
|
||||||
|
|
||||||
|
// Login to get session ID
|
||||||
|
let login_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "AdminSecret123!"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(login_req).await.expect("login request");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let cookie_header = resp
|
||||||
|
.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.expect("set-cookie")
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
|
||||||
|
|
||||||
|
(app, session_cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_public_health_and_version_endpoints() {
|
||||||
|
let (app, _) = setup_test_app().await;
|
||||||
|
|
||||||
|
// Health
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/v1/system/health")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(val["status"], "healthy");
|
||||||
|
assert_eq!(val["database"], "connected");
|
||||||
|
|
||||||
|
// Version
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/v1/system/version")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(val["name"], "nx9-wg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_protected_route_unauthenticated_rejection() {
|
||||||
|
let (app, _) = setup_test_app().await;
|
||||||
|
|
||||||
|
// Request protected route without auth
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/v1/system")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_interfaces_and_peers_rest_lifecycle() {
|
||||||
|
let (app, cookie) = setup_test_app().await;
|
||||||
|
|
||||||
|
// 1. Create interface
|
||||||
|
let create_iface_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/interfaces")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "wg0",
|
||||||
|
"listen_port": 51820,
|
||||||
|
"address_v4": "10.0.0.1/24",
|
||||||
|
"dns": "1.1.1.1"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let iface_val: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let iface_id = iface_val["id"].as_str().unwrap();
|
||||||
|
assert_eq!(iface_val["name"], "wg0");
|
||||||
|
|
||||||
|
// 2. List interfaces
|
||||||
|
let list_req = Request::builder()
|
||||||
|
.uri("/api/v1/interfaces")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 3. Create peer under interface
|
||||||
|
let create_peer_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "laptop-alice",
|
||||||
|
"peer_type": "road_warrior",
|
||||||
|
"profile": "full_tunnel",
|
||||||
|
"allowed_ips": "10.0.0.2/32"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(create_peer_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let peer_val: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let peer_id = peer_val["id"].as_str().unwrap();
|
||||||
|
assert_eq!(peer_val["name"], "laptop-alice");
|
||||||
|
|
||||||
|
// 4. Disable peer
|
||||||
|
let disable_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/peers/{peer_id}/disable"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(disable_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 5. Get peer and verify state
|
||||||
|
let get_peer_req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/peers/{peer_id}"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(get_peer_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let peer_val: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(peer_val["state"], "disabled");
|
||||||
|
|
||||||
|
// 6. Delete interface (cascades peer)
|
||||||
|
let del_iface_req = Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(del_iface_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_networks_and_firewall_rest_lifecycle() {
|
||||||
|
let (app, cookie) = setup_test_app().await;
|
||||||
|
|
||||||
|
// Create network
|
||||||
|
let net_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/networks")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "Management Network",
|
||||||
|
"cidr": "10.10.0.0/16",
|
||||||
|
"description": "Internal management"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(net_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Create firewall rule
|
||||||
|
let fw_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/firewall/rules")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "Allow HTTPS",
|
||||||
|
"direction": "in",
|
||||||
|
"action": "accept",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"destination_port": 443,
|
||||||
|
"priority": 10
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(fw_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let rule_val: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(rule_val["name"], "Allow HTTPS");
|
||||||
|
assert_eq!(rule_val["priority"], 10);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Integration tests for embedded Web UI SPA and static asset endpoints.
|
||||||
|
|
||||||
|
use axum::body::to_bytes;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use nx9_wg_api::routes::build_api_router;
|
||||||
|
use nx9_wg_api::state::AppState;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect store");
|
||||||
|
store.migrate().await.expect("migrate store");
|
||||||
|
let state = AppState::new(store);
|
||||||
|
let app = build_api_router(state);
|
||||||
|
|
||||||
|
// 1. Test GET / (Root SPA Index)
|
||||||
|
let res = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("execute request");
|
||||||
|
|
||||||
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get(axum::http::header::CONTENT_TYPE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"text/html; charset=utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
let body_bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
|
||||||
|
let html = String::from_utf8_lossy(&body_bytes);
|
||||||
|
assert!(html.contains("nx9-wg — Native WireGuard Appliance"));
|
||||||
|
assert!(html.contains("NX9"));
|
||||||
|
assert!(html.contains("id=\"app-layout\""));
|
||||||
|
assert!(html.contains("id=\"sidebar\""));
|
||||||
|
assert!(html.contains("Dashboard"));
|
||||||
|
assert!(html.contains("Peers"));
|
||||||
|
assert!(html.contains("Diagnostics"));
|
||||||
|
assert!(html.contains("Administrator"));
|
||||||
|
|
||||||
|
// 2. Test GET /ui (Alias)
|
||||||
|
let res_ui = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/ui")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("execute request");
|
||||||
|
|
||||||
|
assert_eq!(res_ui.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 3. Test GET /assets/style.css (Compiled CSS)
|
||||||
|
let res_css = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/assets/style.css")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("execute request");
|
||||||
|
|
||||||
|
assert_eq!(res_css.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
res_css
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::CONTENT_TYPE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"text/css; charset=utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
let css_bytes = to_bytes(res_css.into_body(), 1024 * 1024).await.unwrap();
|
||||||
|
let css = String::from_utf8_lossy(&css_bytes);
|
||||||
|
assert!(css.contains("--bg-base: #0d1117;"));
|
||||||
|
assert!(css.contains("[data-theme=\"dark\"]"));
|
||||||
|
assert!(css.contains("[data-theme=\"light\"]"));
|
||||||
|
assert!(css.contains(".status-pass"));
|
||||||
|
assert!(css.contains(".status-fail"));
|
||||||
|
assert!(css.contains("@media (max-width: 768px)"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use nx9_wg_api::{AppState, DiagnosticsService, IpAllocator, ReconciliationEngine};
|
||||||
|
use nx9_wg_core::types::diagnostics::DiagnosticSubsystem;
|
||||||
|
use nx9_wg_core::types::firewall::{
|
||||||
|
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||||
|
};
|
||||||
|
use nx9_wg_core::types::network::Network;
|
||||||
|
use nx9_wg_core::types::wireguard::{
|
||||||
|
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey, WireGuardPublicKey,
|
||||||
|
};
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||||
|
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
async fn setup_test_context() -> (
|
||||||
|
AppState,
|
||||||
|
Arc<SimulatedWireGuardEngine>,
|
||||||
|
Arc<SimulatedNetworkEngine>,
|
||||||
|
Arc<ReconciliationEngine>,
|
||||||
|
) {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect DB");
|
||||||
|
store.migrate().await.expect("migrate DB");
|
||||||
|
|
||||||
|
let state = AppState::new(store);
|
||||||
|
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||||
|
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||||
|
let reconciler = Arc::new(ReconciliationEngine::new(
|
||||||
|
state.clone(),
|
||||||
|
wg_engine.clone(),
|
||||||
|
net_engine.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
(state, wg_engine, net_engine, reconciler)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_automatic_ip_allocation() {
|
||||||
|
let (state, _, _, _) = setup_test_context().await;
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let net_id = Uuid::new_v4();
|
||||||
|
let network = Network {
|
||||||
|
id: net_id,
|
||||||
|
name: "Test-V4-Subnet".to_string(),
|
||||||
|
cidr: "10.50.0.0/24".parse().unwrap(),
|
||||||
|
enabled: true,
|
||||||
|
description: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_network(&network)
|
||||||
|
.await
|
||||||
|
.expect("create net");
|
||||||
|
|
||||||
|
let iface_id = Uuid::new_v4();
|
||||||
|
let iface = Interface {
|
||||||
|
id: iface_id,
|
||||||
|
name: "wg50".to_string(),
|
||||||
|
private_key: WireGuardPrivateKey::new(
|
||||||
|
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||||
|
),
|
||||||
|
public_key: WireGuardPublicKey::new(
|
||||||
|
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||||
|
),
|
||||||
|
listen_port: 51850,
|
||||||
|
address_v4: "10.50.0.1/24".parse().unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: Some(1420),
|
||||||
|
dns: None,
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: None,
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_interface(&iface)
|
||||||
|
.await
|
||||||
|
.expect("create iface");
|
||||||
|
|
||||||
|
// First allocation: 10.50.0.1 is interface -> next available is 10.50.0.2/32
|
||||||
|
let ip1 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
|
||||||
|
.await
|
||||||
|
.expect("allocate ip1");
|
||||||
|
assert_eq!(ip1.to_string(), "10.50.0.2/32");
|
||||||
|
|
||||||
|
// Create a peer with this allocated IP
|
||||||
|
let peer1 = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: iface_id,
|
||||||
|
name: "peer-1".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new(
|
||||||
|
"peer1pubkey12345678901234567890123456789012=".to_string(),
|
||||||
|
),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: ip1.to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: Some(ip1),
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
profile: PeerProfile::FullTunnel,
|
||||||
|
expires_at: None,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state.store.create_peer(&peer1).await.expect("create peer1");
|
||||||
|
|
||||||
|
// Second allocation: next should be 10.50.0.3/32
|
||||||
|
let ip2 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
|
||||||
|
.await
|
||||||
|
.expect("allocate ip2");
|
||||||
|
assert_eq!(ip2.to_string(), "10.50.0.3/32");
|
||||||
|
|
||||||
|
// List available IPs: first should be 10.50.0.3
|
||||||
|
let available = IpAllocator::list_available_ips(&state.store, &network, Some(&iface), 5)
|
||||||
|
.await
|
||||||
|
.expect("list available");
|
||||||
|
assert_eq!(available.len(), 5);
|
||||||
|
assert_eq!(available[0], "10.50.0.3".parse::<IpAddr>().unwrap());
|
||||||
|
assert_eq!(available[1], "10.50.0.4".parse::<IpAddr>().unwrap());
|
||||||
|
|
||||||
|
// List allocations: should show peer1
|
||||||
|
let allocs = IpAllocator::list_allocations(&state.store, &network)
|
||||||
|
.await
|
||||||
|
.expect("list allocs");
|
||||||
|
assert_eq!(allocs.len(), 1);
|
||||||
|
assert_eq!(allocs[0].ip_address, "10.50.0.2/32");
|
||||||
|
assert_eq!(allocs[0].peer_name.as_deref(), Some("peer-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_peer_expiration_lifecycle() {
|
||||||
|
let (state, wg_engine, _net_engine, reconciler) = setup_test_context().await;
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let iface_id = Uuid::new_v4();
|
||||||
|
let iface = Interface {
|
||||||
|
id: iface_id,
|
||||||
|
name: "wg60".to_string(),
|
||||||
|
private_key: WireGuardPrivateKey::new(
|
||||||
|
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||||
|
),
|
||||||
|
public_key: WireGuardPublicKey::new(
|
||||||
|
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||||
|
),
|
||||||
|
listen_port: 51860,
|
||||||
|
address_v4: "10.60.0.1/24".parse().unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: Some(1420),
|
||||||
|
dns: None,
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: None,
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_interface(&iface)
|
||||||
|
.await
|
||||||
|
.expect("create iface");
|
||||||
|
|
||||||
|
// Peer with expiration in the past
|
||||||
|
let expired_peer_id = Uuid::new_v4();
|
||||||
|
let expired_peer = Peer {
|
||||||
|
id: expired_peer_id,
|
||||||
|
interface_id: iface_id,
|
||||||
|
name: "expired-peer".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active, // marked active initially
|
||||||
|
public_key: WireGuardPublicKey::new(
|
||||||
|
"expiredpubkey123456789012345678901234567890=".to_string(),
|
||||||
|
),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "10.60.0.5/32".to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: Some("10.60.0.5/32".parse().unwrap()),
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
profile: PeerProfile::FullTunnel,
|
||||||
|
expires_at: Some(now - Duration::hours(1)), // expired 1 hour ago
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_peer(&expired_peer)
|
||||||
|
.await
|
||||||
|
.expect("create peer");
|
||||||
|
|
||||||
|
// Active peer without expiration
|
||||||
|
let active_peer_id = Uuid::new_v4();
|
||||||
|
let active_peer = Peer {
|
||||||
|
id: active_peer_id,
|
||||||
|
interface_id: iface_id,
|
||||||
|
name: "active-peer".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new(
|
||||||
|
"activepubkey1234567890123456789012345678901=".to_string(),
|
||||||
|
),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "10.60.0.6/32".to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: Some("10.60.0.6/32".parse().unwrap()),
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
profile: PeerProfile::FullTunnel,
|
||||||
|
expires_at: Some(now + Duration::days(30)),
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_peer(&active_peer)
|
||||||
|
.await
|
||||||
|
.expect("create peer");
|
||||||
|
|
||||||
|
// Run reconciliation sweep
|
||||||
|
let swept = reconciler.sweep_expired_peers().await.expect("sweep");
|
||||||
|
assert_eq!(swept, 1);
|
||||||
|
|
||||||
|
// Verify expired_peer transitioned to Expired
|
||||||
|
let p1 = state
|
||||||
|
.store
|
||||||
|
.get_peer(expired_peer_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p1.state, PeerState::Expired);
|
||||||
|
|
||||||
|
// Verify active_peer remains Active
|
||||||
|
let p2 = state
|
||||||
|
.store
|
||||||
|
.get_peer(active_peer_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p2.state, PeerState::Active);
|
||||||
|
|
||||||
|
// Reconcile apply ensures only active peers are synced to WireGuard kernel engine
|
||||||
|
let rep = reconciler.apply().await.expect("apply");
|
||||||
|
assert!(rep.success);
|
||||||
|
|
||||||
|
let stats = wg_engine
|
||||||
|
.get_interface_stats("wg60")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
// Only active peer should be live in interface
|
||||||
|
assert_eq!(stats.peers.len(), 1);
|
||||||
|
assert_eq!(stats.peers[0].public_key, active_peer.public_key.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_peer_firewall_and_port_ranges() {
|
||||||
|
let (state, _, net_engine, reconciler) = setup_test_context().await;
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let iface_id = Uuid::new_v4();
|
||||||
|
let iface = Interface {
|
||||||
|
id: iface_id,
|
||||||
|
name: "wg70".to_string(),
|
||||||
|
private_key: WireGuardPrivateKey::new(
|
||||||
|
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||||
|
),
|
||||||
|
public_key: WireGuardPublicKey::new(
|
||||||
|
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||||
|
),
|
||||||
|
listen_port: 51870,
|
||||||
|
address_v4: "10.70.0.1/24".parse().unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: Some(1420),
|
||||||
|
dns: None,
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: None,
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_interface(&iface)
|
||||||
|
.await
|
||||||
|
.expect("create iface");
|
||||||
|
|
||||||
|
let peer_id = Uuid::new_v4();
|
||||||
|
let peer = Peer {
|
||||||
|
id: peer_id,
|
||||||
|
interface_id: iface_id,
|
||||||
|
name: "dev-peer".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new(
|
||||||
|
"devpeerpubkey1234567890123456789012345678901=".to_string(),
|
||||||
|
),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "10.70.0.10/32".to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: Some("10.70.0.10/32".parse().unwrap()),
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
profile: PeerProfile::FullTunnel,
|
||||||
|
expires_at: None,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state.store.create_peer(&peer).await.expect("create peer");
|
||||||
|
|
||||||
|
// Peer-specific rule with multi-port and TCP/UDP protocol
|
||||||
|
let rule = FirewallRule {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "Allow Dev Ports".to_string(),
|
||||||
|
interface_id: Some(iface_id),
|
||||||
|
peer_id: Some(peer_id),
|
||||||
|
direction: FirewallDirection::Forward,
|
||||||
|
action: FirewallAction::Accept,
|
||||||
|
protocol: FirewallProtocol::TcpUdp,
|
||||||
|
source: None,
|
||||||
|
destination: None,
|
||||||
|
source_port: None,
|
||||||
|
destination_port: None,
|
||||||
|
port_range: Some("8000-8100".to_string()),
|
||||||
|
priority: 10,
|
||||||
|
enabled: true,
|
||||||
|
description: Some("Peer port range".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_firewall_rule(&rule)
|
||||||
|
.await
|
||||||
|
.expect("create rule");
|
||||||
|
|
||||||
|
// Apply reconciliation to compile ruleset
|
||||||
|
reconciler.apply().await.expect("apply");
|
||||||
|
|
||||||
|
let ruleset = net_engine
|
||||||
|
.get_active_nftables_ruleset()
|
||||||
|
.await
|
||||||
|
.expect("get ruleset");
|
||||||
|
assert!(ruleset.contains("table inet nx9_wg"));
|
||||||
|
// Resolved peer IP 10.70.0.10, protocol meta l4proto { tcp, udp }, and port range 8000-8100
|
||||||
|
assert!(ruleset.contains("ip saddr 10.70.0.10"));
|
||||||
|
assert!(ruleset.contains("meta l4proto { tcp, udp }"));
|
||||||
|
assert!(ruleset.contains("th dport 8000-8100 accept"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_native_diagnostics_subsystem() {
|
||||||
|
let (state, wg_engine, net_engine, reconciler) = setup_test_context().await;
|
||||||
|
let diag = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
|
||||||
|
|
||||||
|
let all_reports = diag.diagnose_all().await.expect("diagnose all");
|
||||||
|
assert!(!all_reports.is_empty());
|
||||||
|
|
||||||
|
let sys_report = diag
|
||||||
|
.run_diagnostic(DiagnosticSubsystem::System, None)
|
||||||
|
.await
|
||||||
|
.expect("diag system");
|
||||||
|
assert_eq!(sys_report.len(), 1);
|
||||||
|
assert_eq!(sys_report[0].subsystem, "system");
|
||||||
|
|
||||||
|
let fwd_report = diag
|
||||||
|
.run_diagnostic(DiagnosticSubsystem::Forwarding, None)
|
||||||
|
.await
|
||||||
|
.expect("diag fwd");
|
||||||
|
assert_eq!(fwd_report.len(), 1);
|
||||||
|
assert_eq!(fwd_report[0].subsystem, "forwarding");
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "nx9-wg-core"
|
||||||
|
description = "Core domain types, configuration, and utilities for nx9-wg"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
|
x25519-dalek.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
ipnet.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Application configuration.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub config_file: PathBuf,
|
||||||
|
pub bind_address: SocketAddr,
|
||||||
|
pub log_level: String,
|
||||||
|
pub session_expiry_hours: u64,
|
||||||
|
pub reconciliation_interval_secs: u64,
|
||||||
|
pub backup: BackupConfig,
|
||||||
|
pub bootstrap: Option<BootstrapConfig>,
|
||||||
|
pub admin: Option<AdminConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
data_dir: PathBuf::from("/var/lib/nx9-wg"),
|
||||||
|
config_file: PathBuf::from("/etc/nx9-wg/config.toml"),
|
||||||
|
bind_address: "127.0.0.1:8080".parse().unwrap(),
|
||||||
|
log_level: "info".to_string(),
|
||||||
|
session_expiry_hours: 24,
|
||||||
|
reconciliation_interval_secs: 60,
|
||||||
|
backup: BackupConfig::default(),
|
||||||
|
bootstrap: None,
|
||||||
|
admin: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct BackupConfig {
|
||||||
|
pub dir: PathBuf,
|
||||||
|
pub max_count: usize,
|
||||||
|
pub schedule: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BackupConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
dir: PathBuf::from("/var/lib/nx9-wg/backups"),
|
||||||
|
max_count: 5,
|
||||||
|
schedule: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BootstrapConfig {
|
||||||
|
pub admin_username: Option<String>,
|
||||||
|
pub admin_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for BootstrapConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("BootstrapConfig")
|
||||||
|
.field("admin_username", &self.admin_username)
|
||||||
|
.field(
|
||||||
|
"admin_password",
|
||||||
|
&self.admin_password.as_ref().map(|_| "[REDACTED]"),
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AdminConfig {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AdminConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AdminConfig")
|
||||||
|
.field("username", &self.username)
|
||||||
|
.field(
|
||||||
|
"password_hash",
|
||||||
|
&self.password_hash.as_ref().map(|_| "[REDACTED]"),
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Load configuration from a TOML file, falling back to defaults, then overlaying `NX9_WG_` environment variables.
|
||||||
|
pub fn load(path: &std::path::Path) -> crate::error::Result<Self> {
|
||||||
|
let mut config = if path.exists() {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
toml::from_str(&content).map_err(|e| crate::error::Nx9Error::Config(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
Self::default()
|
||||||
|
};
|
||||||
|
config.apply_env_overrides()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `NX9_WG_` environment variable overrides to this configuration instance.
|
||||||
|
pub fn apply_env_overrides(&mut self) -> crate::error::Result<()> {
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_DATA_DIR") {
|
||||||
|
self.data_dir = PathBuf::from(val);
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_CONFIG") {
|
||||||
|
self.config_file = PathBuf::from(val);
|
||||||
|
}
|
||||||
|
if let Ok(val) =
|
||||||
|
std::env::var("NX9_WG_LISTEN_ADDR").or_else(|_| std::env::var("NX9_WG_BIND_ADDRESS"))
|
||||||
|
{
|
||||||
|
self.bind_address = val.parse().map_err(|e| {
|
||||||
|
crate::error::Nx9Error::Config(format!("invalid NX9_WG_LISTEN_ADDR '{val}': {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_LOG_LEVEL") {
|
||||||
|
self.log_level = val;
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_SESSION_TIMEOUT")
|
||||||
|
.or_else(|_| std::env::var("NX9_WG_SESSION_EXPIRY_HOURS"))
|
||||||
|
{
|
||||||
|
self.session_expiry_hours = val.parse().map_err(|e| {
|
||||||
|
crate::error::Nx9Error::Config(format!(
|
||||||
|
"invalid NX9_WG_SESSION_TIMEOUT '{val}': {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_RECONCILIATION_INTERVAL")
|
||||||
|
.or_else(|_| std::env::var("NX9_WG_RECONCILIATION_INTERVAL_SECS"))
|
||||||
|
{
|
||||||
|
self.reconciliation_interval_secs = val.parse().map_err(|e| {
|
||||||
|
crate::error::Nx9Error::Config(format!(
|
||||||
|
"invalid NX9_WG_RECONCILIATION_INTERVAL '{val}': {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_BACKUP_DIR") {
|
||||||
|
self.backup.dir = PathBuf::from(val);
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_BACKUP_MAX_COUNT") {
|
||||||
|
self.backup.max_count = val.parse().map_err(|e| {
|
||||||
|
crate::error::Nx9Error::Config(format!(
|
||||||
|
"invalid NX9_WG_BACKUP_MAX_COUNT '{val}': {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("NX9_WG_BACKUP_SCHEDULE") {
|
||||||
|
self.backup.schedule = Some(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap environment variables
|
||||||
|
let env_user = std::env::var("NX9_WG_ADMIN_USERNAME").ok();
|
||||||
|
let env_pass = std::env::var("NX9_WG_ADMIN_PASSWORD").ok();
|
||||||
|
if env_user.is_some() || env_pass.is_some() {
|
||||||
|
let mut boot = self.bootstrap.take().unwrap_or(BootstrapConfig {
|
||||||
|
admin_username: None,
|
||||||
|
admin_password: None,
|
||||||
|
});
|
||||||
|
if let Some(u) = env_user {
|
||||||
|
boot.admin_username = Some(u);
|
||||||
|
}
|
||||||
|
if let Some(p) = env_pass {
|
||||||
|
boot.admin_password = Some(p);
|
||||||
|
}
|
||||||
|
self.bootstrap = Some(boot);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let cfg = AppConfig::default();
|
||||||
|
assert_eq!(cfg.log_level, "info");
|
||||||
|
assert_eq!(cfg.session_expiry_hours, 24);
|
||||||
|
assert_eq!(cfg.reconciliation_interval_secs, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_secret_redaction_in_debug() {
|
||||||
|
let boot = BootstrapConfig {
|
||||||
|
admin_username: Some("admin".to_string()),
|
||||||
|
admin_password: Some("supersecret123".to_string()),
|
||||||
|
};
|
||||||
|
let debug_str = format!("{boot:?}");
|
||||||
|
assert!(!debug_str.contains("supersecret123"));
|
||||||
|
assert!(debug_str.contains("[REDACTED]"));
|
||||||
|
|
||||||
|
let admin = AdminConfig {
|
||||||
|
username: Some("admin".to_string()),
|
||||||
|
password_hash: Some("$argon2id$...".to_string()),
|
||||||
|
};
|
||||||
|
let debug_str = format!("{admin:?}");
|
||||||
|
assert!(!debug_str.contains("$argon2id$..."));
|
||||||
|
assert!(debug_str.contains("[REDACTED]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_env_overrides_application() {
|
||||||
|
let mut cfg = AppConfig::default();
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("NX9_WG_DATA_DIR", "/tmp/nx9_test_data");
|
||||||
|
std::env::set_var("NX9_WG_LOG_LEVEL", "debug");
|
||||||
|
std::env::set_var("NX9_WG_SESSION_TIMEOUT", "48");
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.apply_env_overrides().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(cfg.data_dir, PathBuf::from("/tmp/nx9_test_data"));
|
||||||
|
assert_eq!(cfg.log_level, "debug");
|
||||||
|
assert_eq!(cfg.session_expiry_hours, 48);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("NX9_WG_DATA_DIR");
|
||||||
|
std::env::remove_var("NX9_WG_LOG_LEVEL");
|
||||||
|
std::env::remove_var("NX9_WG_SESSION_TIMEOUT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! Cryptographic utilities.
|
||||||
|
|
||||||
|
use crate::error::{Nx9Error, Result};
|
||||||
|
use crate::types::wireguard::{WireGuardPresharedKey, WireGuardPrivateKey, WireGuardPublicKey};
|
||||||
|
|
||||||
|
/// Hash a password with Argon2id. Returns the PHC-formatted hash string.
|
||||||
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
use argon2::Argon2;
|
||||||
|
use argon2::password_hash::rand_core::OsRng;
|
||||||
|
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map(|h| h.to_string())
|
||||||
|
.map_err(|e| Nx9Error::Crypto(format!("password hashing failed: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a password against an Argon2id PHC hash string.
|
||||||
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||||
|
use argon2::Argon2;
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordVerifier};
|
||||||
|
let parsed_hash = PasswordHash::new(hash)
|
||||||
|
.map_err(|e| Nx9Error::Crypto(format!("invalid password hash: {}", e)))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a WireGuard key pair (x25519).
|
||||||
|
pub fn generate_keypair() -> (WireGuardPrivateKey, WireGuardPublicKey) {
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&secret);
|
||||||
|
let priv_b64 = STANDARD.encode(secret.to_bytes());
|
||||||
|
let pub_b64 = STANDARD.encode(public.as_bytes());
|
||||||
|
(
|
||||||
|
WireGuardPrivateKey::new(priv_b64),
|
||||||
|
WireGuardPublicKey::new(pub_b64),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a WireGuard preshared key (32 random bytes, base64).
|
||||||
|
pub fn generate_preshared_key() -> WireGuardPresharedKey {
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut key);
|
||||||
|
WireGuardPresharedKey::new(STANDARD.encode(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a session ID (UUID v4).
|
||||||
|
pub fn generate_session_id() -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an API token. Returns (plaintext_token, sha256_hex_hash).
|
||||||
|
pub fn generate_api_token() -> (String, String) {
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut token_bytes = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut token_bytes);
|
||||||
|
let plaintext = format!("nx9_{}", URL_SAFE_NO_PAD.encode(token_bytes));
|
||||||
|
let hash_bytes = Sha256::digest(plaintext.as_bytes());
|
||||||
|
let hash = hash_bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect::<String>();
|
||||||
|
(plaintext, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a cryptographically secure random password.
|
||||||
|
pub fn generate_secure_password(length: usize) -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
const CHARSET: &[u8] =
|
||||||
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
|
||||||
|
let mut rng = rand::rngs::OsRng;
|
||||||
|
(0..length)
|
||||||
|
.map(|_| {
|
||||||
|
let idx = rng.gen_range(0..CHARSET.len());
|
||||||
|
CHARSET[idx] as char
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_password_hashing() {
|
||||||
|
let password = "my_secure_password";
|
||||||
|
let hash = hash_password(password).unwrap();
|
||||||
|
assert!(verify_password(password, &hash).unwrap());
|
||||||
|
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_keypair() {
|
||||||
|
let (priv_key, pub_key) = generate_keypair();
|
||||||
|
assert!(!priv_key.as_str().is_empty());
|
||||||
|
assert!(!pub_key.as_str().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_api_token() {
|
||||||
|
let (token, hash) = generate_api_token();
|
||||||
|
assert!(token.starts_with("nx9_"));
|
||||||
|
assert_eq!(hash.len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_secure_password() {
|
||||||
|
let pw = generate_secure_password(16);
|
||||||
|
assert_eq!(pw.len(), 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! Error types for nx9-wg.
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Nx9Error {
|
||||||
|
/// Validation error
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
/// Database error
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(String),
|
||||||
|
/// WireGuard error
|
||||||
|
#[error("WireGuard error: {0}")]
|
||||||
|
WireGuard(String),
|
||||||
|
/// Network error
|
||||||
|
#[error("Network error: {0}")]
|
||||||
|
Network(String),
|
||||||
|
/// Authentication error
|
||||||
|
#[error("Auth error: {0}")]
|
||||||
|
Auth(String),
|
||||||
|
/// Cryptography error
|
||||||
|
#[error("Crypto error: {0}")]
|
||||||
|
Crypto(String),
|
||||||
|
/// Backup error
|
||||||
|
#[error("Backup error: {0}")]
|
||||||
|
Backup(String),
|
||||||
|
/// Configuration error
|
||||||
|
#[error("Config error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
/// I/O error
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
/// Internal error
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Nx9Error>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! Core domain types, configuration, and utilities for nx9-wg.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod error;
|
||||||
|
pub mod types;
|
||||||
|
pub mod validation;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! Audit types.
|
||||||
|
|
||||||
|
use crate::error::Nx9Error;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AuditEventType {
|
||||||
|
Login,
|
||||||
|
Logout,
|
||||||
|
LoginFailed,
|
||||||
|
AdminInitialized,
|
||||||
|
PasswordChange,
|
||||||
|
TotpChange,
|
||||||
|
ApiTokenCreate,
|
||||||
|
ApiTokenRevoke,
|
||||||
|
InterfaceCreate,
|
||||||
|
InterfaceUpdate,
|
||||||
|
InterfaceDelete,
|
||||||
|
InterfaceEnable,
|
||||||
|
InterfaceDisable,
|
||||||
|
PeerCreate,
|
||||||
|
PeerUpdate,
|
||||||
|
PeerDelete,
|
||||||
|
PeerEnable,
|
||||||
|
PeerDisable,
|
||||||
|
PeerRevoke,
|
||||||
|
PeerExpire,
|
||||||
|
PeerRotateKeys,
|
||||||
|
NetworkCreate,
|
||||||
|
NetworkUpdate,
|
||||||
|
NetworkDelete,
|
||||||
|
RouteCreate,
|
||||||
|
RouteUpdate,
|
||||||
|
RouteDelete,
|
||||||
|
RouteEnable,
|
||||||
|
RouteDisable,
|
||||||
|
FirewallCreate,
|
||||||
|
FirewallUpdate,
|
||||||
|
FirewallDelete,
|
||||||
|
BackupCreate,
|
||||||
|
BackupRestore,
|
||||||
|
BackupDelete,
|
||||||
|
SessionInvalidated,
|
||||||
|
SettingsUpdate,
|
||||||
|
ReconciliationRun,
|
||||||
|
ReconciliationFailed,
|
||||||
|
ImportCompleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuditEventType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Login => "login",
|
||||||
|
Self::Logout => "logout",
|
||||||
|
Self::LoginFailed => "login_failed",
|
||||||
|
Self::AdminInitialized => "admin_initialized",
|
||||||
|
Self::PasswordChange => "password_change",
|
||||||
|
Self::TotpChange => "totp_change",
|
||||||
|
Self::ApiTokenCreate => "api_token_create",
|
||||||
|
Self::ApiTokenRevoke => "api_token_revoke",
|
||||||
|
Self::InterfaceCreate => "interface_create",
|
||||||
|
Self::InterfaceUpdate => "interface_update",
|
||||||
|
Self::InterfaceDelete => "interface_delete",
|
||||||
|
Self::InterfaceEnable => "interface_enable",
|
||||||
|
Self::InterfaceDisable => "interface_disable",
|
||||||
|
Self::PeerCreate => "peer_create",
|
||||||
|
Self::PeerUpdate => "peer_update",
|
||||||
|
Self::PeerDelete => "peer_delete",
|
||||||
|
Self::PeerEnable => "peer_enable",
|
||||||
|
Self::PeerDisable => "peer_disable",
|
||||||
|
Self::PeerRevoke => "peer_revoke",
|
||||||
|
Self::PeerExpire => "peer_expire",
|
||||||
|
Self::PeerRotateKeys => "peer_rotate_keys",
|
||||||
|
Self::NetworkCreate => "network_create",
|
||||||
|
Self::NetworkUpdate => "network_update",
|
||||||
|
Self::NetworkDelete => "network_delete",
|
||||||
|
Self::RouteCreate => "route_create",
|
||||||
|
Self::RouteUpdate => "route_update",
|
||||||
|
Self::RouteDelete => "route_delete",
|
||||||
|
Self::RouteEnable => "route_enable",
|
||||||
|
Self::RouteDisable => "route_disable",
|
||||||
|
Self::FirewallCreate => "firewall_create",
|
||||||
|
Self::FirewallUpdate => "firewall_update",
|
||||||
|
Self::FirewallDelete => "firewall_delete",
|
||||||
|
Self::BackupCreate => "backup_create",
|
||||||
|
Self::BackupRestore => "backup_restore",
|
||||||
|
Self::BackupDelete => "backup_delete",
|
||||||
|
Self::SessionInvalidated => "session_invalidated",
|
||||||
|
Self::SettingsUpdate => "settings_update",
|
||||||
|
Self::ReconciliationRun => "reconciliation_run",
|
||||||
|
Self::ReconciliationFailed => "reconciliation_failed",
|
||||||
|
Self::ImportCompleted => "import_completed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AuditEventType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for AuditEventType {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let v = match s {
|
||||||
|
"login" => Self::Login,
|
||||||
|
"logout" => Self::Logout,
|
||||||
|
"login_failed" => Self::LoginFailed,
|
||||||
|
"admin_initialized" => Self::AdminInitialized,
|
||||||
|
"password_change" => Self::PasswordChange,
|
||||||
|
"totp_change" => Self::TotpChange,
|
||||||
|
"api_token_create" => Self::ApiTokenCreate,
|
||||||
|
"api_token_revoke" => Self::ApiTokenRevoke,
|
||||||
|
"interface_create" => Self::InterfaceCreate,
|
||||||
|
"interface_update" => Self::InterfaceUpdate,
|
||||||
|
"interface_delete" => Self::InterfaceDelete,
|
||||||
|
"interface_enable" => Self::InterfaceEnable,
|
||||||
|
"interface_disable" => Self::InterfaceDisable,
|
||||||
|
"peer_create" => Self::PeerCreate,
|
||||||
|
"peer_update" => Self::PeerUpdate,
|
||||||
|
"peer_delete" => Self::PeerDelete,
|
||||||
|
"peer_enable" => Self::PeerEnable,
|
||||||
|
"peer_disable" => Self::PeerDisable,
|
||||||
|
"peer_revoke" => Self::PeerRevoke,
|
||||||
|
"peer_expire" => Self::PeerExpire,
|
||||||
|
"peer_rotate_keys" => Self::PeerRotateKeys,
|
||||||
|
"network_create" => Self::NetworkCreate,
|
||||||
|
"network_update" => Self::NetworkUpdate,
|
||||||
|
"network_delete" => Self::NetworkDelete,
|
||||||
|
"route_create" => Self::RouteCreate,
|
||||||
|
"route_update" => Self::RouteUpdate,
|
||||||
|
"route_delete" => Self::RouteDelete,
|
||||||
|
"route_enable" => Self::RouteEnable,
|
||||||
|
"route_disable" => Self::RouteDisable,
|
||||||
|
"firewall_create" => Self::FirewallCreate,
|
||||||
|
"firewall_update" => Self::FirewallUpdate,
|
||||||
|
"firewall_delete" => Self::FirewallDelete,
|
||||||
|
"backup_create" => Self::BackupCreate,
|
||||||
|
"backup_restore" => Self::BackupRestore,
|
||||||
|
"backup_delete" => Self::BackupDelete,
|
||||||
|
"session_invalidated" => Self::SessionInvalidated,
|
||||||
|
"settings_update" => Self::SettingsUpdate,
|
||||||
|
"reconciliation_run" => Self::ReconciliationRun,
|
||||||
|
"reconciliation_failed" => Self::ReconciliationFailed,
|
||||||
|
"import_completed" => Self::ImportCompleted,
|
||||||
|
_ => {
|
||||||
|
return Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid AuditEventType: {}",
|
||||||
|
s
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuditEvent {
|
||||||
|
pub id: i64,
|
||||||
|
pub event_type: AuditEventType,
|
||||||
|
pub actor: String,
|
||||||
|
pub resource_type: Option<String>,
|
||||||
|
pub resource_id: Option<String>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audit_event_type_roundtrip() {
|
||||||
|
assert_eq!(
|
||||||
|
AuditEventType::from_str("login").unwrap(),
|
||||||
|
AuditEventType::Login
|
||||||
|
);
|
||||||
|
assert_eq!(AuditEventType::Login.to_string(), "login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Authentication types.
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Admin {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub totp_secret: Option<String>,
|
||||||
|
pub totp_enabled: bool,
|
||||||
|
pub last_login_at: Option<NaiveDateTime>,
|
||||||
|
pub last_login_ip: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Admin {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Admin")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("username", &self.username)
|
||||||
|
.field("password_hash", &"[REDACTED]")
|
||||||
|
.field(
|
||||||
|
"totp_secret",
|
||||||
|
&self.totp_secret.as_ref().map(|_| "[REDACTED]"),
|
||||||
|
)
|
||||||
|
.field("totp_enabled", &self.totp_enabled)
|
||||||
|
.field("last_login_at", &self.last_login_at)
|
||||||
|
.field("last_login_ip", &self.last_login_ip)
|
||||||
|
.field("created_at", &self.created_at)
|
||||||
|
.field("updated_at", &self.updated_at)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String,
|
||||||
|
pub admin_id: i64,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub expires_at: NaiveDateTime,
|
||||||
|
pub last_seen_at: Option<NaiveDateTime>,
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiToken {
|
||||||
|
pub id: String,
|
||||||
|
pub admin_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ApiToken {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ApiToken")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("admin_id", &self.admin_id)
|
||||||
|
.field("name", &self.name)
|
||||||
|
.field("token_hash", &"[REDACTED]")
|
||||||
|
.field("created_at", &self.created_at)
|
||||||
|
.field("expires_at", &self.expires_at)
|
||||||
|
.field("last_used_at", &self.last_used_at)
|
||||||
|
.field("revoked_at", &self.revoked_at)
|
||||||
|
.field("revoked", &self.revoked)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoginAttempt {
|
||||||
|
pub id: i64,
|
||||||
|
pub ip_address: String,
|
||||||
|
pub attempted_at: NaiveDateTime,
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//! Backup types.
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BackupManifest {
|
||||||
|
pub version: String,
|
||||||
|
pub schema_version: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub checksum: String,
|
||||||
|
pub encrypted: bool,
|
||||||
|
pub files: Vec<BackupFileEntry>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BackupFileEntry {
|
||||||
|
pub path: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub checksum: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BackupMeta {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub filename: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub checksum: String,
|
||||||
|
pub schema_version: String,
|
||||||
|
pub encrypted: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Client environment and MTU profile domain models.
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Connection environment type for client profiles.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ConnectionType {
|
||||||
|
Web,
|
||||||
|
Mobile,
|
||||||
|
Wifi,
|
||||||
|
Wired,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Web => "web",
|
||||||
|
Self::Mobile => "mobile",
|
||||||
|
Self::Wifi => "wifi",
|
||||||
|
Self::Wired => "wired",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ConnectionType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ConnectionType {
|
||||||
|
type Err = crate::error::Nx9Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"web" => Ok(Self::Web),
|
||||||
|
"mobile" | "cellular" | "lte" | "5g" => Ok(Self::Mobile),
|
||||||
|
"wifi" | "wi-fi" | "wireless" => Ok(Self::Wifi),
|
||||||
|
"wired" | "ethernet" | "lan" => Ok(Self::Wired),
|
||||||
|
"other" | "unknown" => Ok(Self::Other),
|
||||||
|
_ => Err(crate::error::Nx9Error::Validation(format!(
|
||||||
|
"invalid connection type '{s}', expected: web, mobile, wifi, wired, other"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Network Address Translation (NAT) conditions for client profiles.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NatType {
|
||||||
|
Direct,
|
||||||
|
Cgnat,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Direct => "direct",
|
||||||
|
Self::Cgnat => "cgnat",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for NatType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for NatType {
|
||||||
|
type Err = crate::error::Nx9Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"direct" | "public" | "open" => Ok(Self::Direct),
|
||||||
|
"cgnat" | "nat444" | "carrier_grade_nat" => Ok(Self::Cgnat),
|
||||||
|
"unknown" | "n/a" | "none" => Ok(Self::Unknown),
|
||||||
|
_ => Err(crate::error::Nx9Error::Validation(format!(
|
||||||
|
"invalid nat type '{s}', expected: direct, cgnat, unknown"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client device platform categories.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DeviceCategory {
|
||||||
|
Android,
|
||||||
|
Ios,
|
||||||
|
Linux,
|
||||||
|
Windows,
|
||||||
|
Macos,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceCategory {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Android => "android",
|
||||||
|
Self::Ios => "ios",
|
||||||
|
Self::Linux => "linux",
|
||||||
|
Self::Windows => "windows",
|
||||||
|
Self::Macos => "macos",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for DeviceCategory {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DeviceCategory {
|
||||||
|
type Err = crate::error::Nx9Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"android" => Ok(Self::Android),
|
||||||
|
"ios" | "iphone" | "ipad" => Ok(Self::Ios),
|
||||||
|
"linux" | "unix" => Ok(Self::Linux),
|
||||||
|
"windows" | "win" => Ok(Self::Windows),
|
||||||
|
"macos" | "mac" | "darwin" | "osx" => Ok(Self::Macos),
|
||||||
|
"other" | "embedded" | "router" => Ok(Self::Other),
|
||||||
|
_ => Err(crate::error::Nx9Error::Validation(format!(
|
||||||
|
"invalid device category '{s}', expected: android, ios, linux, windows, macos, other"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authoritative client profile definition.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ClientProfile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub device: Option<DeviceCategory>,
|
||||||
|
pub connection_type: ConnectionType,
|
||||||
|
pub nat_type: NatType,
|
||||||
|
pub mtu: u16,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub persistent_keepalive: Option<u16>,
|
||||||
|
pub is_builtin: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dynamically resolved client profile for configuration export.
|
||||||
|
///
|
||||||
|
/// NOTE: Endpoint and AllowedIPs belong to the authoritative WireGuard peer
|
||||||
|
/// configuration and MUST NOT be silently overridden by a generic client environment profile.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ResolvedClientProfile {
|
||||||
|
pub mtu: u16,
|
||||||
|
pub persistent_keepalive: Option<u16>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub is_manually_overridden: bool,
|
||||||
|
pub applied_profile_id: String,
|
||||||
|
pub applied_profile_name: String,
|
||||||
|
pub connection_type: ConnectionType,
|
||||||
|
pub nat_type: NatType,
|
||||||
|
pub device: Option<DeviceCategory>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_type_roundtrip() {
|
||||||
|
let types = [
|
||||||
|
(ConnectionType::Web, "web"),
|
||||||
|
(ConnectionType::Mobile, "mobile"),
|
||||||
|
(ConnectionType::Wifi, "wifi"),
|
||||||
|
(ConnectionType::Wired, "wired"),
|
||||||
|
(ConnectionType::Other, "other"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (variant, name) in types {
|
||||||
|
assert_eq!(variant.as_str(), name);
|
||||||
|
assert_eq!(variant.to_string(), name);
|
||||||
|
assert_eq!(ConnectionType::from_str(name).unwrap(), variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(ConnectionType::from_str("invalid").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nat_type_roundtrip() {
|
||||||
|
let types = [
|
||||||
|
(NatType::Direct, "direct"),
|
||||||
|
(NatType::Cgnat, "cgnat"),
|
||||||
|
(NatType::Unknown, "unknown"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (variant, name) in types {
|
||||||
|
assert_eq!(variant.as_str(), name);
|
||||||
|
assert_eq!(variant.to_string(), name);
|
||||||
|
assert_eq!(NatType::from_str(name).unwrap(), variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(NatType::from_str("invalid").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_device_category_roundtrip() {
|
||||||
|
let categories = [
|
||||||
|
(DeviceCategory::Android, "android"),
|
||||||
|
(DeviceCategory::Ios, "ios"),
|
||||||
|
(DeviceCategory::Linux, "linux"),
|
||||||
|
(DeviceCategory::Windows, "windows"),
|
||||||
|
(DeviceCategory::Macos, "macos"),
|
||||||
|
(DeviceCategory::Other, "other"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (variant, name) in categories {
|
||||||
|
assert_eq!(variant.as_str(), name);
|
||||||
|
assert_eq!(variant.to_string(), name);
|
||||||
|
assert_eq!(DeviceCategory::from_str(name).unwrap(), variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(DeviceCategory::from_str("invalid").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! Diagnostic types and models.
|
||||||
|
|
||||||
|
use crate::error::Nx9Error;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Evaluation status for an individual diagnostic check or report.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DiagnosticStatus {
|
||||||
|
Pass,
|
||||||
|
Warning,
|
||||||
|
Fail,
|
||||||
|
NotApplicable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiagnosticStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pass => "pass",
|
||||||
|
Self::Warning => "warning",
|
||||||
|
Self::Fail => "fail",
|
||||||
|
Self::NotApplicable => "not_applicable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for DiagnosticStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DiagnosticStatus {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"pass" | "ok" => Ok(Self::Pass),
|
||||||
|
"warning" | "warn" => Ok(Self::Warning),
|
||||||
|
"fail" | "failed" | "error" => Ok(Self::Fail),
|
||||||
|
"not_applicable" | "n/a" | "na" => Ok(Self::NotApplicable),
|
||||||
|
_ => Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid DiagnosticStatus: {s}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Target subsystem for diagnostics.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DiagnosticSubsystem {
|
||||||
|
System,
|
||||||
|
Network,
|
||||||
|
Wan,
|
||||||
|
Wireguard,
|
||||||
|
Peer,
|
||||||
|
Routing,
|
||||||
|
Forwarding,
|
||||||
|
Firewall,
|
||||||
|
Nat,
|
||||||
|
Mtu,
|
||||||
|
Reconciliation,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiagnosticSubsystem {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::System => "system",
|
||||||
|
Self::Network => "network",
|
||||||
|
Self::Wan => "wan",
|
||||||
|
Self::Wireguard => "wireguard",
|
||||||
|
Self::Peer => "peer",
|
||||||
|
Self::Routing => "routing",
|
||||||
|
Self::Forwarding => "forwarding",
|
||||||
|
Self::Firewall => "firewall",
|
||||||
|
Self::Nat => "nat",
|
||||||
|
Self::Mtu => "mtu",
|
||||||
|
Self::Reconciliation => "reconciliation",
|
||||||
|
Self::All => "all",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for DiagnosticSubsystem {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DiagnosticSubsystem {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"system" => Ok(Self::System),
|
||||||
|
"network" => Ok(Self::Network),
|
||||||
|
"wan" => Ok(Self::Wan),
|
||||||
|
"wireguard" | "wg" => Ok(Self::Wireguard),
|
||||||
|
"peer" => Ok(Self::Peer),
|
||||||
|
"routing" | "routes" | "route" => Ok(Self::Routing),
|
||||||
|
"forwarding" | "fwd" => Ok(Self::Forwarding),
|
||||||
|
"firewall" | "fw" | "nft" | "nftables" => Ok(Self::Firewall),
|
||||||
|
"nat" => Ok(Self::Nat),
|
||||||
|
"mtu" => Ok(Self::Mtu),
|
||||||
|
"reconciliation" | "reconcile" => Ok(Self::Reconciliation),
|
||||||
|
"all" => Ok(Self::All),
|
||||||
|
_ => Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid DiagnosticSubsystem: {s}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An individual diagnostic check observation and result.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DiagnosticCheck {
|
||||||
|
pub check_name: String,
|
||||||
|
pub status: DiagnosticStatus,
|
||||||
|
pub observed_value: String,
|
||||||
|
pub expected_value: Option<String>,
|
||||||
|
pub diagnostic_message: String,
|
||||||
|
pub remediation_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated report for a diagnostic subsystem evaluation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DiagnosticReport {
|
||||||
|
pub subsystem: String,
|
||||||
|
pub timestamp: NaiveDateTime,
|
||||||
|
pub overall_status: DiagnosticStatus,
|
||||||
|
pub checks: Vec<DiagnosticCheck>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_diagnostic_status_roundtrip() {
|
||||||
|
assert_eq!(
|
||||||
|
DiagnosticStatus::from_str("pass").unwrap(),
|
||||||
|
DiagnosticStatus::Pass
|
||||||
|
);
|
||||||
|
assert_eq!(DiagnosticStatus::Pass.to_string(), "pass");
|
||||||
|
assert_eq!(
|
||||||
|
DiagnosticStatus::from_str("warning").unwrap(),
|
||||||
|
DiagnosticStatus::Warning
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DiagnosticStatus::from_str("fail").unwrap(),
|
||||||
|
DiagnosticStatus::Fail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! Firewall types.
|
||||||
|
|
||||||
|
use crate::error::Nx9Error;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FirewallAction {
|
||||||
|
Accept,
|
||||||
|
Drop,
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirewallAction {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Accept => "accept",
|
||||||
|
Self::Drop => "drop",
|
||||||
|
Self::Reject => "reject",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for FirewallAction {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for FirewallAction {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"accept" => Ok(Self::Accept),
|
||||||
|
"drop" => Ok(Self::Drop),
|
||||||
|
"reject" => Ok(Self::Reject),
|
||||||
|
_ => Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid FirewallAction: {}",
|
||||||
|
s
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FirewallProtocol {
|
||||||
|
Tcp,
|
||||||
|
Udp,
|
||||||
|
TcpUdp,
|
||||||
|
Icmp,
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirewallProtocol {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Tcp => "tcp",
|
||||||
|
Self::Udp => "udp",
|
||||||
|
Self::TcpUdp => "tcp_udp",
|
||||||
|
Self::Icmp => "icmp",
|
||||||
|
Self::Any => "any",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for FirewallProtocol {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for FirewallProtocol {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"tcp" => Ok(Self::Tcp),
|
||||||
|
"udp" => Ok(Self::Udp),
|
||||||
|
"tcp_udp" | "tcp/udp" => Ok(Self::TcpUdp),
|
||||||
|
"icmp" => Ok(Self::Icmp),
|
||||||
|
"any" => Ok(Self::Any),
|
||||||
|
_ => Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid FirewallProtocol: {}",
|
||||||
|
s
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FirewallDirection {
|
||||||
|
In,
|
||||||
|
Out,
|
||||||
|
Forward,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirewallDirection {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::In => "in",
|
||||||
|
Self::Out => "out",
|
||||||
|
Self::Forward => "forward",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for FirewallDirection {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for FirewallDirection {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"in" => Ok(Self::In),
|
||||||
|
"out" => Ok(Self::Out),
|
||||||
|
"forward" => Ok(Self::Forward),
|
||||||
|
_ => Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid FirewallDirection: {}",
|
||||||
|
s
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FirewallRule {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
pub peer_id: Option<Uuid>,
|
||||||
|
pub direction: FirewallDirection,
|
||||||
|
pub action: FirewallAction,
|
||||||
|
pub protocol: FirewallProtocol,
|
||||||
|
pub source: Option<String>,
|
||||||
|
pub destination: Option<String>,
|
||||||
|
pub source_port: Option<u16>,
|
||||||
|
pub destination_port: Option<u16>,
|
||||||
|
pub port_range: Option<String>,
|
||||||
|
pub priority: i32,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! Domain types.
|
||||||
|
|
||||||
|
pub mod audit;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod client_profile;
|
||||||
|
pub mod diagnostics;
|
||||||
|
pub mod firewall;
|
||||||
|
pub mod network;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod wireguard;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//! Network types.
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Network {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub cidr: IpNet,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Route {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub network_id: Option<Uuid>,
|
||||||
|
pub interface_id: Option<Uuid>,
|
||||||
|
pub destination: IpNet,
|
||||||
|
pub gateway: Option<IpAddr>,
|
||||||
|
pub interface_name: Option<String>,
|
||||||
|
pub metric: Option<u32>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Settings domain types.
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// System setting key-value pair.
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Setting {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub is_secret: bool,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Setting {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Setting")
|
||||||
|
.field("key", &self.key)
|
||||||
|
.field(
|
||||||
|
"value",
|
||||||
|
if self.is_secret {
|
||||||
|
&"[REDACTED]" as &dyn std::fmt::Debug
|
||||||
|
} else {
|
||||||
|
&self.value as &dyn std::fmt::Debug
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.field("is_secret", &self.is_secret)
|
||||||
|
.field("updated_at", &self.updated_at)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
//! WireGuard types.
|
||||||
|
|
||||||
|
use crate::error::Nx9Error;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WireGuardPrivateKey(String);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for WireGuardPrivateKey {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "[REDACTED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WireGuardPrivateKey {
|
||||||
|
pub fn new(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, std::hash::Hash, Serialize, Deserialize)]
|
||||||
|
pub struct WireGuardPublicKey(String);
|
||||||
|
|
||||||
|
impl WireGuardPublicKey {
|
||||||
|
pub fn new(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for WireGuardPublicKey {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WireGuardPresharedKey(String);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for WireGuardPresharedKey {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "[REDACTED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WireGuardPresharedKey {
|
||||||
|
pub fn new(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PeerType {
|
||||||
|
RoadWarrior,
|
||||||
|
SiteGateway,
|
||||||
|
Server,
|
||||||
|
Relay,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PeerType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::RoadWarrior => "road_warrior",
|
||||||
|
Self::SiteGateway => "site_gateway",
|
||||||
|
Self::Server => "server",
|
||||||
|
Self::Relay => "relay",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for PeerType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for PeerType {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"road_warrior" => Ok(Self::RoadWarrior),
|
||||||
|
"site_gateway" => Ok(Self::SiteGateway),
|
||||||
|
"server" => Ok(Self::Server),
|
||||||
|
"relay" => Ok(Self::Relay),
|
||||||
|
_ => Err(Nx9Error::Validation(format!("invalid PeerType: {}", s))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PeerState {
|
||||||
|
Active,
|
||||||
|
Disabled,
|
||||||
|
Revoked,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PeerState {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Disabled => "disabled",
|
||||||
|
Self::Revoked => "revoked",
|
||||||
|
Self::Expired => "expired",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for PeerState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for PeerState {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"active" => Ok(Self::Active),
|
||||||
|
"disabled" => Ok(Self::Disabled),
|
||||||
|
"revoked" => Ok(Self::Revoked),
|
||||||
|
"expired" => Ok(Self::Expired),
|
||||||
|
_ => Err(Nx9Error::Validation(format!("invalid PeerState: {}", s))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PeerProfile {
|
||||||
|
FullTunnel,
|
||||||
|
SplitTunnel,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PeerProfile {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::FullTunnel => "full_tunnel",
|
||||||
|
Self::SplitTunnel => "split_tunnel",
|
||||||
|
Self::Custom => "custom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for PeerProfile {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for PeerProfile {
|
||||||
|
type Err = Nx9Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"full_tunnel" => Ok(Self::FullTunnel),
|
||||||
|
"split_tunnel" => Ok(Self::SplitTunnel),
|
||||||
|
"custom" => Ok(Self::Custom),
|
||||||
|
_ => Err(Nx9Error::Validation(format!("invalid PeerProfile: {}", s))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Interface {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub private_key: WireGuardPrivateKey,
|
||||||
|
pub public_key: WireGuardPublicKey,
|
||||||
|
pub listen_port: u16,
|
||||||
|
pub address_v4: IpNet,
|
||||||
|
pub address_v6: Option<IpNet>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub pre_up: Option<String>,
|
||||||
|
pub post_up: Option<String>,
|
||||||
|
pub pre_down: Option<String>,
|
||||||
|
pub post_down: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Peer {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub interface_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub peer_type: PeerType,
|
||||||
|
pub state: PeerState,
|
||||||
|
pub public_key: WireGuardPublicKey,
|
||||||
|
pub private_key: Option<WireGuardPrivateKey>,
|
||||||
|
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<NaiveDateTime>,
|
||||||
|
pub last_handshake_at: Option<NaiveDateTime>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_peer_type_roundtrip() {
|
||||||
|
assert_eq!(
|
||||||
|
PeerType::from_str("road_warrior").unwrap(),
|
||||||
|
PeerType::RoadWarrior
|
||||||
|
);
|
||||||
|
assert_eq!(PeerType::RoadWarrior.to_string(), "road_warrior");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_private_key_debug() {
|
||||||
|
let pk = WireGuardPrivateKey::new("secret".to_string());
|
||||||
|
assert_eq!(format!("{:?}", pk), "[REDACTED]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
//! Validation utilities.
|
||||||
|
|
||||||
|
use crate::error::{Nx9Error, Result};
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Parsed and validated firewall port specification.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PortSpec {
|
||||||
|
Single(u16),
|
||||||
|
Range(u16, u16),
|
||||||
|
List(Vec<u16>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for PortSpec {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Single(p) => write!(f, "{p}"),
|
||||||
|
Self::Range(start, end) => write!(f, "{start}-{end}"),
|
||||||
|
Self::List(ports) => {
|
||||||
|
let s: Vec<String> = ports.iter().map(|p| p.to_string()).collect();
|
||||||
|
write!(f, "{}", s.join(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate and parse a port specification (single "443", range "8000-8100", or list "53,80,443").
|
||||||
|
pub fn validate_port_spec(spec: &str) -> Result<PortSpec> {
|
||||||
|
let trimmed = spec.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"port specification cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.contains('-') {
|
||||||
|
let parts: Vec<&str> = trimmed.split('-').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid port range format '{spec}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let start_str = parts[0].trim();
|
||||||
|
let end_str = parts[1].trim();
|
||||||
|
if start_str.is_empty() || end_str.is_empty() {
|
||||||
|
return Err(Nx9Error::Validation(format!(
|
||||||
|
"invalid port range format '{spec}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let start: u16 = start_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| Nx9Error::Validation(format!("invalid start port in range '{spec}'")))?;
|
||||||
|
let end: u16 = end_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| Nx9Error::Validation(format!("invalid end port in range '{spec}'")))?;
|
||||||
|
|
||||||
|
if start == 0 || end == 0 {
|
||||||
|
return Err(Nx9Error::Validation("ports must be non-zero".into()));
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
return Err(Nx9Error::Validation(format!(
|
||||||
|
"port range start ({start}) cannot exceed end ({end})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if start == end {
|
||||||
|
return Ok(PortSpec::Single(start));
|
||||||
|
}
|
||||||
|
return Ok(PortSpec::Range(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.contains(',') {
|
||||||
|
let mut ports = Vec::new();
|
||||||
|
for p_str in trimmed.split(',') {
|
||||||
|
let p_trim = p_str.trim();
|
||||||
|
if p_trim.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p: u16 = p_trim
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| Nx9Error::Validation(format!("invalid port number '{p_trim}'")))?;
|
||||||
|
if p == 0 {
|
||||||
|
return Err(Nx9Error::Validation("ports must be non-zero".into()));
|
||||||
|
}
|
||||||
|
if !ports.contains(&p) {
|
||||||
|
ports.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ports.is_empty() {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"port list must contain at least one valid port".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ports.sort_unstable();
|
||||||
|
if ports.len() == 1 {
|
||||||
|
return Ok(PortSpec::Single(ports[0]));
|
||||||
|
}
|
||||||
|
return Ok(PortSpec::List(ports));
|
||||||
|
}
|
||||||
|
|
||||||
|
let p: u16 = trimmed
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| Nx9Error::Validation(format!("invalid port number '{spec}'")))?;
|
||||||
|
if p == 0 {
|
||||||
|
return Err(Nx9Error::Validation("port must be non-zero".into()));
|
||||||
|
}
|
||||||
|
Ok(PortSpec::Single(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate CIDR.
|
||||||
|
pub fn validate_cidr(cidr: &str) -> Result<IpNet> {
|
||||||
|
IpNet::from_str(cidr)
|
||||||
|
.map_err(|e| Nx9Error::Validation(format!("invalid CIDR '{}': {}", cidr, e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate IP.
|
||||||
|
pub fn validate_ip(ip: &str) -> Result<IpAddr> {
|
||||||
|
IpAddr::from_str(ip)
|
||||||
|
.map_err(|e| Nx9Error::Validation(format!("invalid IP address '{}': {}", ip, e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that an IP address belongs to a specified subnet network and matches address family.
|
||||||
|
pub fn validate_ip_in_network(ip: IpAddr, net: IpNet) -> Result<()> {
|
||||||
|
match (ip, net) {
|
||||||
|
(IpAddr::V4(_), IpNet::V6(_)) => {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"address family mismatch: IPv4 address against IPv6 network".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(IpAddr::V6(_), IpNet::V4(_)) => {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"address family mismatch: IPv6 address against IPv4 network".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !net.contains(&ip) {
|
||||||
|
return Err(Nx9Error::Validation(format!(
|
||||||
|
"IP address '{ip}' is outside network CIDR '{net}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate port.
|
||||||
|
pub fn validate_port(port: u16) -> Result<u16> {
|
||||||
|
if port == 0 {
|
||||||
|
return Err(Nx9Error::Validation("port must be non-zero".into()));
|
||||||
|
}
|
||||||
|
Ok(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate interface name.
|
||||||
|
pub fn validate_interface_name(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"interface name cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if name.len() > 15 {
|
||||||
|
return Err(Nx9Error::Validation("interface name max 15 chars".into()));
|
||||||
|
}
|
||||||
|
if !name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"interface name contains invalid characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !name.chars().next().unwrap().is_ascii_alphabetic() {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"interface name must start with letter".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate peer name.
|
||||||
|
pub fn validate_peer_name(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(Nx9Error::Validation("peer name cannot be empty".into()));
|
||||||
|
}
|
||||||
|
if name.len() > 64 {
|
||||||
|
return Err(Nx9Error::Validation("peer name max 64 chars".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
||||||
|
pub const MAX_PASSWORD_LENGTH: usize = 256;
|
||||||
|
|
||||||
|
/// Validate password strength.
|
||||||
|
pub fn validate_password_strength(password: &str) -> Result<()> {
|
||||||
|
if password.len() < MIN_PASSWORD_LENGTH {
|
||||||
|
return Err(Nx9Error::Validation("password too short".into()));
|
||||||
|
}
|
||||||
|
if password.len() > MAX_PASSWORD_LENGTH {
|
||||||
|
return Err(Nx9Error::Validation("password too long".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate listen port.
|
||||||
|
pub fn validate_listen_port(port: u16) -> Result<u16> {
|
||||||
|
if port < 1024 {
|
||||||
|
return Err(Nx9Error::Validation("cannot use privileged port".into()));
|
||||||
|
}
|
||||||
|
validate_port(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate MTU.
|
||||||
|
pub fn validate_mtu(mtu: u16) -> Result<u16> {
|
||||||
|
if mtu < 1280 {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"MTU too small, minimum is 1280".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if mtu > 9000 {
|
||||||
|
return Err(Nx9Error::Validation(
|
||||||
|
"MTU too large, maximum is 9000".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(mtu)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate client MTU.
|
||||||
|
///
|
||||||
|
/// Rules:
|
||||||
|
/// - Normal range: 1280 to 1500
|
||||||
|
/// - Jumbo frames: up to 9000 (allowed without silent clamping)
|
||||||
|
/// - Invalid: < 1280 or > 9000 (rejected with error)
|
||||||
|
pub fn validate_client_mtu(mtu: u16) -> Result<u16> {
|
||||||
|
if mtu < 1280 {
|
||||||
|
return Err(Nx9Error::Validation(format!(
|
||||||
|
"client MTU {mtu} is below IPv6 minimum MTU (1280)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if mtu > 9000 {
|
||||||
|
return Err(Nx9Error::Validation(format!(
|
||||||
|
"client MTU {mtu} exceeds maximum allowable jumbo frame MTU (9000)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(mtu)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_cidr() {
|
||||||
|
assert!(validate_cidr("192.168.1.0/24").is_ok());
|
||||||
|
assert!(validate_cidr("invalid").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_ip() {
|
||||||
|
assert!(validate_ip("10.0.0.1").is_ok());
|
||||||
|
assert!(validate_ip("256.0.0.1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_port_spec() {
|
||||||
|
assert_eq!(validate_port_spec("443").unwrap(), PortSpec::Single(443));
|
||||||
|
assert_eq!(
|
||||||
|
validate_port_spec("8000-8100").unwrap(),
|
||||||
|
PortSpec::Range(8000, 8100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_port_spec("53,80,443").unwrap(),
|
||||||
|
PortSpec::List(vec![53, 80, 443])
|
||||||
|
);
|
||||||
|
assert!(validate_port_spec("8100-8000").is_err());
|
||||||
|
assert!(validate_port_spec("0").is_err());
|
||||||
|
assert!(validate_port_spec("70000").is_err());
|
||||||
|
assert!(validate_port_spec("").is_err());
|
||||||
|
assert!(validate_port_spec("abc").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_ip_in_network() {
|
||||||
|
let net_v4: IpNet = "10.0.0.0/24".parse().unwrap();
|
||||||
|
assert!(validate_ip_in_network("10.0.0.5".parse().unwrap(), net_v4).is_ok());
|
||||||
|
assert!(validate_ip_in_network("192.168.1.1".parse().unwrap(), net_v4).is_err());
|
||||||
|
|
||||||
|
let net_v6: IpNet = "fd00::/64".parse().unwrap();
|
||||||
|
assert!(validate_ip_in_network("fd00::1".parse().unwrap(), net_v6).is_ok());
|
||||||
|
assert!(validate_ip_in_network("10.0.0.1".parse().unwrap(), net_v6).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_port() {
|
||||||
|
assert!(validate_port(8080).is_ok());
|
||||||
|
assert!(validate_port(0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_interface_name() {
|
||||||
|
assert!(validate_interface_name("wg0").is_ok());
|
||||||
|
assert!(validate_interface_name("0wg").is_err());
|
||||||
|
assert!(validate_interface_name("verylonginterfacenamehere").is_err());
|
||||||
|
assert!(validate_interface_name("wg@0").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_password_strength() {
|
||||||
|
assert!(validate_password_strength("strongpassword").is_ok());
|
||||||
|
assert!(validate_password_strength("short").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_client_mtu() {
|
||||||
|
// Below minimum
|
||||||
|
assert!(validate_client_mtu(1279).is_err());
|
||||||
|
assert!(validate_client_mtu(576).is_err());
|
||||||
|
assert!(validate_client_mtu(0).is_err());
|
||||||
|
|
||||||
|
// Normal values
|
||||||
|
assert_eq!(validate_client_mtu(1280).unwrap(), 1280);
|
||||||
|
assert_eq!(validate_client_mtu(1360).unwrap(), 1360);
|
||||||
|
assert_eq!(validate_client_mtu(1420).unwrap(), 1420);
|
||||||
|
assert_eq!(validate_client_mtu(1500).unwrap(), 1500);
|
||||||
|
|
||||||
|
// Jumbo frame values
|
||||||
|
assert_eq!(validate_client_mtu(1501).unwrap(), 1501);
|
||||||
|
assert_eq!(validate_client_mtu(9000).unwrap(), 9000);
|
||||||
|
|
||||||
|
// Above maximum
|
||||||
|
assert!(validate_client_mtu(9001).is_err());
|
||||||
|
assert!(validate_client_mtu(65535).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Automated validation suite for environment variable namespace and precedence.
|
||||||
|
|
||||||
|
use nx9_wg_core::config::{AppConfig, BootstrapConfig};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nx9_wg_env_variable_precedence_and_overrides() {
|
||||||
|
let _lock = ENV_MUTEX.lock().unwrap();
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
|
||||||
|
// Set canonical NX9_WG_ environment variables
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("NX9_WG_CONFIG", "/custom/etc/config.toml");
|
||||||
|
std::env::set_var("NX9_WG_DATA_DIR", "/custom/var/data");
|
||||||
|
std::env::set_var("NX9_WG_LISTEN_ADDR", "127.0.0.1:9090");
|
||||||
|
std::env::set_var("NX9_WG_LOG_LEVEL", "warn");
|
||||||
|
std::env::set_var("NX9_WG_SESSION_TIMEOUT", "72");
|
||||||
|
std::env::set_var("NX9_WG_RECONCILIATION_INTERVAL", "15");
|
||||||
|
std::env::set_var("NX9_WG_BACKUP_DIR", "/custom/backups");
|
||||||
|
std::env::set_var("NX9_WG_BACKUP_MAX_COUNT", "20");
|
||||||
|
std::env::set_var("NX9_WG_BACKUP_SCHEDULE", "0 3 * * *");
|
||||||
|
std::env::set_var("NX9_WG_ADMIN_USERNAME", "superadmin");
|
||||||
|
std::env::set_var("NX9_WG_ADMIN_PASSWORD", "SuperSecretPW987!");
|
||||||
|
}
|
||||||
|
|
||||||
|
config.apply_env_overrides().expect("apply env overrides");
|
||||||
|
|
||||||
|
assert_eq!(config.config_file, PathBuf::from("/custom/etc/config.toml"));
|
||||||
|
assert_eq!(config.data_dir, PathBuf::from("/custom/var/data"));
|
||||||
|
assert_eq!(config.bind_address, "127.0.0.1:9090".parse().unwrap());
|
||||||
|
assert_eq!(config.log_level, "warn");
|
||||||
|
assert_eq!(config.session_expiry_hours, 72);
|
||||||
|
assert_eq!(config.reconciliation_interval_secs, 15);
|
||||||
|
assert_eq!(config.backup.dir, PathBuf::from("/custom/backups"));
|
||||||
|
assert_eq!(config.backup.max_count, 20);
|
||||||
|
assert_eq!(config.backup.schedule, Some("0 3 * * *".to_string()));
|
||||||
|
|
||||||
|
let boot = config.bootstrap.expect("bootstrap should be present");
|
||||||
|
assert_eq!(boot.admin_username, Some("superadmin".to_string()));
|
||||||
|
assert_eq!(boot.admin_password, Some("SuperSecretPW987!".to_string()));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("NX9_WG_CONFIG");
|
||||||
|
std::env::remove_var("NX9_WG_DATA_DIR");
|
||||||
|
std::env::remove_var("NX9_WG_LISTEN_ADDR");
|
||||||
|
std::env::remove_var("NX9_WG_LOG_LEVEL");
|
||||||
|
std::env::remove_var("NX9_WG_SESSION_TIMEOUT");
|
||||||
|
std::env::remove_var("NX9_WG_RECONCILIATION_INTERVAL");
|
||||||
|
std::env::remove_var("NX9_WG_BACKUP_DIR");
|
||||||
|
std::env::remove_var("NX9_WG_BACKUP_MAX_COUNT");
|
||||||
|
std::env::remove_var("NX9_WG_BACKUP_SCHEDULE");
|
||||||
|
std::env::remove_var("NX9_WG_ADMIN_USERNAME");
|
||||||
|
std::env::remove_var("NX9_WG_ADMIN_PASSWORD");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_env_variable_values() {
|
||||||
|
let _lock = ENV_MUTEX.lock().unwrap();
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("NX9_WG_LISTEN_ADDR", "invalid-ip-and-port");
|
||||||
|
}
|
||||||
|
assert!(config.apply_env_overrides().is_err());
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("NX9_WG_LISTEN_ADDR");
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("NX9_WG_SESSION_TIMEOUT", "not-a-number");
|
||||||
|
}
|
||||||
|
assert!(config.apply_env_overrides().is_err());
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("NX9_WG_SESSION_TIMEOUT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_secret_redaction() {
|
||||||
|
let boot = BootstrapConfig {
|
||||||
|
admin_username: Some("admin".to_string()),
|
||||||
|
admin_password: Some("secret12345".to_string()),
|
||||||
|
};
|
||||||
|
let formatted = format!("{boot:?}");
|
||||||
|
assert!(!formatted.contains("secret12345"));
|
||||||
|
assert!(formatted.contains("[REDACTED]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_database_path_resolution() {
|
||||||
|
// Default database path should be data_dir/nx9-wg.db
|
||||||
|
let config = AppConfig::default();
|
||||||
|
let expected_db = config.data_dir.join("nx9-wg.db");
|
||||||
|
assert_eq!(expected_db, PathBuf::from("/var/lib/nx9-wg/nx9-wg.db"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_explicit_database_dir_override() {
|
||||||
|
let _lock = ENV_MUTEX.lock().unwrap();
|
||||||
|
let config = AppConfig::default();
|
||||||
|
// When --database is explicitly provided, it should be used directly
|
||||||
|
// The test verifies the default path is what we expect
|
||||||
|
assert_eq!(config.data_dir, PathBuf::from("/var/lib/nx9-wg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backup_directory_consistency() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
// Backup directory should always be consistent: /var/lib/nx9-wg/backups
|
||||||
|
assert_eq!(config.backup.dir, PathBuf::from("/var/lib/nx9-wg/backups"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nx9_wg_database_env_var() {
|
||||||
|
let _lock = ENV_MUTEX.lock().unwrap();
|
||||||
|
// NX9_WG_DATABASE should be honored via CLI args
|
||||||
|
// This test documents that the env var is in the canonical namespace
|
||||||
|
let cli_args = &["--database", "/custom/path/test.db"];
|
||||||
|
// We're verifying this is the correct pattern to use
|
||||||
|
assert_eq!(cli_args[0], "--database");
|
||||||
|
assert_eq!(cli_args[1], "/custom/path/test.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_env_namespace_only() {
|
||||||
|
let _lock = ENV_MUTEX.lock().unwrap();
|
||||||
|
// Verify only NX9_WG_* variables are used
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
|
||||||
|
// Try setting a non-canonical variable - should be ignored
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("RUST_LOG", "debug");
|
||||||
|
std::env::set_var("NX9_LOG_LEVEL", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
config.apply_env_overrides().expect("apply env overrides");
|
||||||
|
|
||||||
|
// These non-canonical variables should be ignored
|
||||||
|
assert_eq!(config.log_level, "info"); // Should remain default
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("RUST_LOG");
|
||||||
|
std::env::remove_var("NX9_LOG_LEVEL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_bind_address_is_localhost() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
// Verify bind address default
|
||||||
|
assert_eq!(config.bind_address, "127.0.0.1:8080".parse().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_reconciliation_interval() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
// Default reconciliation interval should be 60 seconds
|
||||||
|
assert_eq!(config.reconciliation_interval_secs, 60);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "nx9-wg-db"
|
||||||
|
description = "SQLite persistence layer for nx9-wg"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nx9-wg-core.workspace = true
|
||||||
|
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "uuid"] }
|
||||||
|
tokio.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
ipnet.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# nx9-db — SQLite Persistence Layer
|
||||||
|
|
||||||
|
`nx9-db` provides the authoritative SQLite persistence layer for the `nx9-wg` native Rust WireGuard management system.
|
||||||
|
|
||||||
|
## Architectural Boundaries
|
||||||
|
|
||||||
|
- **Authoritative State**: SQLite is the authoritative persistent store for `nx9-wg` desired state. It stores what the system intends the network, interfaces, peers, routes, firewall rules, administrator credentials, sessions, tokens, and settings to be.
|
||||||
|
- **Separation of Concerns**: SQLite records desired configuration only. Live kernel state (WireGuard interface status, handshake counters, packet counters, live nftables rules, live kernel routes) is queried directly from Linux kernel subsystems in later phases.
|
||||||
|
- **SQL Encapsulation**: All SQL queries, SQLite connection lifecycle, migrations, and row conversions are strictly encapsulated inside `nx9-db`. Neither `nx9-core`, `nx9-api`, `nx9-ui`, `nx9-wireguard`, nor `nx9-network` issue SQL directly.
|
||||||
|
|
||||||
|
## SQLite Configuration
|
||||||
|
|
||||||
|
Every connection opened by `Store` enforces:
|
||||||
|
- `PRAGMA journal_mode = WAL` — Write-Ahead Logging for high-concurrency read/write operations.
|
||||||
|
- `PRAGMA foreign_keys = ON` — Strict relational integrity across all tables.
|
||||||
|
- `PRAGMA busy_timeout = 5000` — 5-second busy timeout to avoid contention errors.
|
||||||
|
- `PRAGMA synchronous = NORMAL` — Optimal reliability and performance in WAL mode.
|
||||||
|
|
||||||
|
## Database Schema (12 Tables)
|
||||||
|
|
||||||
|
1. `admin` — Single administrator identity (`CHECK (id = 1)`), Argon2id password hash, TOTP secrets, and login timestamp.
|
||||||
|
2. `sessions` — Admin web sessions (`ON DELETE CASCADE`).
|
||||||
|
3. `login_attempts` — IP-based login attempt tracking for brute-force rate limiting.
|
||||||
|
4. `api_tokens` — Hashed API tokens for automation (`ON DELETE CASCADE`).
|
||||||
|
5. `interfaces` — Desired WireGuard interfaces (`wg0`, `wg1`, etc.), private/public keys, listen port, IPv4/IPv6 CIDRs, MTU, DNS.
|
||||||
|
6. `peers` — Desired WireGuard peer definitions, classifications (`road_warrior`, `site_gateway`, `server`, `relay`), states (`active`, `disabled`, `revoked`, `expired`), profiles (`full_tunnel`, `split_tunnel`, `custom`), public/private/preshared keys, AllowedIPs, endpoints, and persistent keepalives (`ON DELETE CASCADE`).
|
||||||
|
7. `networks` — Named network CIDRs for routing and organization.
|
||||||
|
8. `routes` — Desired kernel routing rules (`ON DELETE SET NULL`).
|
||||||
|
9. `firewall_rules` — Desired firewall policy rules with priorities and directions (`in`, `out`, `forward`).
|
||||||
|
10. `settings` — Key-value system settings with secret redaction support.
|
||||||
|
11. `audit_events` — Append-only operational audit log with event filtering and pagination.
|
||||||
|
12. `backups` — Backup metadata and manifest checksum records.
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
- Migrations are defined in `crates/nx9-db/migrations/` and embedded at compile time via `sqlx::migrate!("./migrations")`.
|
||||||
|
- Migrations are executed automatically via `store.migrate().await?`.
|
||||||
|
- Migrations are tracked in the `_sqlx_migrations` table for idempotency.
|
||||||
|
|
||||||
|
## Usage in Code
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use nx9_db::Store;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Connect and auto-migrate
|
||||||
|
let store = Store::connect_path(Path::new("/var/lib/nx9-wg/nx9-wg.db")).await?;
|
||||||
|
store.migrate().await?;
|
||||||
|
|
||||||
|
// Create single admin if not initialized
|
||||||
|
if !store.admin_exists().await? {
|
||||||
|
store.create_admin("admin", "$argon2id$...").await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Tests use isolated in-memory or temporary file SQLite instances:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p nx9-db
|
||||||
|
```
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
------------------------------------------------------------------------
|
||||||
|
-- nx9-wg SQLite Initial Migration (0001_initial.sql)
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 1. Admin (Exactly one row, id=1 enforced by CHECK)
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE admin (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
totp_secret TEXT,
|
||||||
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_login_at TEXT,
|
||||||
|
last_login_ip TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_admin_username ON admin(username);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 2. Sessions
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_id INTEGER NOT NULL DEFAULT 1 REFERENCES admin(id) ON DELETE CASCADE,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
CREATE INDEX idx_sessions_admin_id ON sessions(admin_id);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 3. Login Attempts (Brute force protection)
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE login_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip_address TEXT NOT NULL,
|
||||||
|
attempted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
success INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_login_attempts_ip ON login_attempts(ip_address, attempted_at);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 4. API Tokens
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE api_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_id INTEGER NOT NULL DEFAULT 1 REFERENCES admin(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX idx_api_tokens_expires_at ON api_tokens(expires_at);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 5. WireGuard Interfaces
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE interfaces (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 51820,
|
||||||
|
ipv4_cidr TEXT NOT NULL,
|
||||||
|
ipv6_cidr TEXT,
|
||||||
|
mtu INTEGER,
|
||||||
|
dns TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
pre_up TEXT,
|
||||||
|
post_up TEXT,
|
||||||
|
pre_down TEXT,
|
||||||
|
post_down TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_interfaces_name ON interfaces(name);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 6. Peers / Clients
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE peers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
interface_id TEXT NOT NULL REFERENCES interfaces(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
peer_type TEXT NOT NULL DEFAULT 'road_warrior'
|
||||||
|
CHECK (peer_type IN ('road_warrior', 'site_gateway', 'server', 'relay')),
|
||||||
|
state TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (state IN ('active', 'disabled', 'revoked', 'expired')),
|
||||||
|
profile TEXT NOT NULL DEFAULT 'full_tunnel'
|
||||||
|
CHECK (profile IN ('full_tunnel', 'split_tunnel', 'custom')),
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
private_key TEXT,
|
||||||
|
preshared_key TEXT,
|
||||||
|
endpoint TEXT,
|
||||||
|
allowed_ips TEXT NOT NULL,
|
||||||
|
server_allowed_ips TEXT,
|
||||||
|
address_ipv4 TEXT,
|
||||||
|
address_ipv6 TEXT,
|
||||||
|
dns TEXT,
|
||||||
|
mtu INTEGER,
|
||||||
|
persistent_keepalive INTEGER,
|
||||||
|
expires_at TEXT,
|
||||||
|
last_handshake_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(interface_id, name),
|
||||||
|
UNIQUE(interface_id, public_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_peers_interface_id ON peers(interface_id);
|
||||||
|
CREATE INDEX idx_peers_name ON peers(interface_id, name);
|
||||||
|
CREATE INDEX idx_peers_state ON peers(state);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 7. Networks
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE networks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
cidr TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_networks_name ON networks(name);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 8. Routes
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE routes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
network_id TEXT REFERENCES networks(id) ON DELETE SET NULL,
|
||||||
|
interface_id TEXT REFERENCES interfaces(id) ON DELETE SET NULL,
|
||||||
|
destination TEXT NOT NULL,
|
||||||
|
gateway TEXT,
|
||||||
|
metric INTEGER,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_routes_network_id ON routes(network_id);
|
||||||
|
CREATE INDEX idx_routes_interface_id ON routes(interface_id);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 9. Firewall Rules
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE firewall_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
interface_id TEXT REFERENCES interfaces(id) ON DELETE SET NULL,
|
||||||
|
direction TEXT NOT NULL DEFAULT 'in'
|
||||||
|
CHECK (direction IN ('in', 'out', 'forward')),
|
||||||
|
action TEXT NOT NULL DEFAULT 'accept'
|
||||||
|
CHECK (action IN ('accept', 'drop', 'reject')),
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'any'
|
||||||
|
CHECK (protocol IN ('tcp', 'udp', 'tcp_udp', 'icmp', 'any')),
|
||||||
|
source TEXT,
|
||||||
|
destination TEXT,
|
||||||
|
source_port INTEGER,
|
||||||
|
destination_port INTEGER,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_firewall_interface_priority ON firewall_rules(interface_id, priority);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 10. Settings (Key-Value)
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 11. Audit Events (Append-only)
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE audit_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
message TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_audit_created_at ON audit_events(created_at);
|
||||||
|
CREATE INDEX idx_audit_event_type ON audit_events(event_type);
|
||||||
|
CREATE INDEX idx_audit_resource ON audit_events(resource_type, resource_id);
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
-- 12. Backups (Metadata)
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
CREATE TABLE backups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
encrypted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
schema_version TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_backups_created_at ON backups(created_at);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 0002_wiregui_capabilities.sql
|
||||||
|
-- Add peer-specific firewall association and structured port semantics
|
||||||
|
|
||||||
|
ALTER TABLE firewall_rules ADD COLUMN peer_id TEXT REFERENCES peers(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE firewall_rules ADD COLUMN port_range TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_firewall_peer_id ON firewall_rules(peer_id);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- 0003_client_profiles.sql
|
||||||
|
-- Client Environment and MTU Profile System
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS client_profiles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
provider TEXT,
|
||||||
|
device TEXT CHECK (device IS NULL OR device IN ('android', 'ios', 'linux', 'windows', 'macos', 'other')),
|
||||||
|
connection_type TEXT NOT NULL CHECK (connection_type IN ('web', 'mobile', 'wifi', 'wired', 'other')),
|
||||||
|
nat_type TEXT NOT NULL DEFAULT 'unknown' CHECK (nat_type IN ('direct', 'cgnat', 'unknown')),
|
||||||
|
mtu INTEGER NOT NULL CHECK (mtu >= 1280 AND mtu <= 9000),
|
||||||
|
dns TEXT,
|
||||||
|
persistent_keepalive INTEGER CHECK (persistent_keepalive IS NULL OR (persistent_keepalive >= 0 AND persistent_keepalive <= 65535)),
|
||||||
|
is_builtin BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_client_profiles_provider ON client_profiles(provider);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_client_profiles_device ON client_profiles(device);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_client_profiles_connection ON client_profiles(connection_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_client_profiles_nat ON client_profiles(nat_type);
|
||||||
|
|
||||||
|
-- Insert authoritative built-in client profiles
|
||||||
|
INSERT OR IGNORE INTO client_profiles (id, name, provider, device, connection_type, nat_type, mtu, dns, persistent_keepalive, is_builtin, description, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
('default-mobile', 'Default Mobile', NULL, NULL, 'mobile', 'unknown', 1280, NULL, 25, 1, 'Standard mobile carrier profile with 1280 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('default-cgnat', 'Default CGNAT', NULL, NULL, 'other', 'cgnat', 1360, NULL, 25, 1, 'Carrier-grade NAT environment profile with 1360 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('default-wifi', 'Default Wi-Fi', NULL, NULL, 'wifi', 'unknown', 1420, NULL, 25, 1, 'Standard Wi-Fi wireless profile with 1420 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('default-web', 'Default Web', NULL, NULL, 'web', 'unknown', 1420, NULL, 25, 1, 'Standard Web client profile with 1420 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('default-wired', 'Default Wired', NULL, NULL, 'wired', 'direct', 1420, NULL, 25, 1, 'High-throughput wired Ethernet profile with 1420 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('android-mobile', 'Android Mobile', NULL, 'android', 'mobile', 'unknown', 1280, NULL, 25, 1, 'Android cellular client profile with 1280 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('ios-mobile', 'iOS Mobile', NULL, 'ios', 'mobile', 'unknown', 1280, NULL, 25, 1, 'Apple iOS cellular profile with 1280 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('tmobile-mobile', 'T-Mobile Mobile', 'tmobile', NULL, 'mobile', 'cgnat', 1280, NULL, 25, 1, 'T-Mobile US IPv6/CGNAT mobile profile with 1280 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('verizon-mobile', 'Verizon Mobile', 'verizon', NULL, 'mobile', 'cgnat', 1280, NULL, 25, 1, 'Verizon Wireless mobile profile with 1280 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('jio-mobile', 'Jio Mobile', 'jio', NULL, 'mobile', 'cgnat', 1280, NULL, 25, 1, 'Reliance Jio 4G/5G mobile profile with 1280 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('starlink-cgnat', 'Starlink CGNAT', 'starlink', NULL, 'other', 'cgnat', 1360, NULL, 25, 1, 'Starlink satellite CGNAT profile with 1360 MTU and 25s keepalive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! Administrator repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::auth::Admin;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
/// Retrieve the single administrator record, if initialized.
|
||||||
|
pub async fn get_admin(pool: &SqlitePool) -> Result<Option<Admin>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, username, password_hash, totp_secret, totp_enabled,
|
||||||
|
last_login_at, last_login_ip, created_at, updated_at
|
||||||
|
FROM admin
|
||||||
|
WHERE id = 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let id: i64 = r.try_get("id")?;
|
||||||
|
let username: String = r.try_get("username")?;
|
||||||
|
let password_hash: String = r.try_get("password_hash")?;
|
||||||
|
let totp_secret: Option<String> = r.try_get("totp_secret")?;
|
||||||
|
let totp_enabled_int: i64 = r.try_get("totp_enabled")?;
|
||||||
|
let last_login_at_str: Option<String> = r.try_get("last_login_at")?;
|
||||||
|
let last_login_ip: Option<String> = r.try_get("last_login_ip")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
let last_login_at = match last_login_at_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(Admin {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
totp_secret,
|
||||||
|
totp_enabled: totp_enabled_int != 0,
|
||||||
|
last_login_at,
|
||||||
|
last_login_ip,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the administrator record by username.
|
||||||
|
pub async fn get_admin_by_username(pool: &SqlitePool, username: &str) -> Result<Option<Admin>> {
|
||||||
|
let admin = get_admin(pool).await?;
|
||||||
|
match admin {
|
||||||
|
Some(a) if a.username == username => Ok(Some(a)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the single administrator has already been initialized.
|
||||||
|
pub async fn admin_exists(pool: &SqlitePool) -> Result<bool> {
|
||||||
|
let row = sqlx::query("SELECT COUNT(*) as count FROM admin WHERE id = 1")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let count: i64 = row.try_get("count")?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the single administrator record.
|
||||||
|
///
|
||||||
|
/// Fails if an administrator already exists.
|
||||||
|
pub async fn create_admin(pool: &SqlitePool, username: &str, password_hash: &str) -> Result<Admin> {
|
||||||
|
if admin_exists(pool).await? {
|
||||||
|
return Err(DbError::Conflict(
|
||||||
|
"Administrator has already been initialized".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO admin (id, username, password_hash, totp_secret, totp_enabled, created_at, updated_at)
|
||||||
|
VALUES (1, ?, ?, NULL, 0, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||||
|
DbError::Conflict("Administrator already exists or username conflict".to_string())
|
||||||
|
}
|
||||||
|
_ => DbError::Sqlx(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Admin {
|
||||||
|
id: 1,
|
||||||
|
username: username.to_string(),
|
||||||
|
password_hash: password_hash.to_string(),
|
||||||
|
totp_secret: None,
|
||||||
|
totp_enabled: false,
|
||||||
|
last_login_at: None,
|
||||||
|
last_login_ip: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the administrator's password hash.
|
||||||
|
pub async fn update_admin_password(pool: &SqlitePool, new_password_hash: &str) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE admin
|
||||||
|
SET password_hash = ?, updated_at = ?
|
||||||
|
WHERE id = 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(new_password_hash)
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(
|
||||||
|
"Administrator record does not exist".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update administrator TOTP configuration.
|
||||||
|
pub async fn update_admin_totp(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
totp_secret: Option<&str>,
|
||||||
|
totp_enabled: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE admin
|
||||||
|
SET totp_secret = ?, totp_enabled = ?, updated_at = ?
|
||||||
|
WHERE id = 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(totp_secret)
|
||||||
|
.bind(if totp_enabled { 1 } else { 0 })
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(
|
||||||
|
"Administrator record does not exist".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful administrator login timestamp and IP address.
|
||||||
|
pub async fn record_admin_login(pool: &SqlitePool, ip_address: Option<&str>) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE admin
|
||||||
|
SET last_login_at = ?, last_login_ip = ?, updated_at = ?
|
||||||
|
WHERE id = 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(ip_address)
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(
|
||||||
|
"Administrator record does not exist".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete administrator record (if explicitly supported).
|
||||||
|
pub async fn delete_admin(pool: &SqlitePool) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM admin WHERE id = 1")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! Operational Audit log repository operations (append-only).
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use nx9_wg_core::types::audit::{AuditEvent, AuditEventType};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Filter options for querying audit records.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct AuditFilter {
|
||||||
|
pub event_type: Option<AuditEventType>,
|
||||||
|
pub resource_type: Option<String>,
|
||||||
|
pub resource_id: Option<String>,
|
||||||
|
pub since: Option<NaiveDateTime>,
|
||||||
|
pub until: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a new audit event to the log.
|
||||||
|
pub async fn create_audit_event(pool: &SqlitePool, event: &AuditEvent) -> Result<i64> {
|
||||||
|
let created_at_str = format_datetime(&event.created_at);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_events (
|
||||||
|
event_type, actor, resource_type, resource_id,
|
||||||
|
message, metadata, ip_address, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(event.event_type.as_str())
|
||||||
|
.bind(&event.actor)
|
||||||
|
.bind(&event.resource_type)
|
||||||
|
.bind(&event.resource_id)
|
||||||
|
.bind(&event.message)
|
||||||
|
.bind(&event.metadata)
|
||||||
|
.bind(&event.ip_address)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(result.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function to record an audit entry.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn record_audit(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
event_type: AuditEventType,
|
||||||
|
actor: &str,
|
||||||
|
resource_type: Option<&str>,
|
||||||
|
resource_id: Option<&str>,
|
||||||
|
message: Option<&str>,
|
||||||
|
metadata: Option<&str>,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let event = AuditEvent {
|
||||||
|
id: 0,
|
||||||
|
event_type,
|
||||||
|
actor: actor.to_string(),
|
||||||
|
resource_type: resource_type.map(|s| s.to_string()),
|
||||||
|
resource_id: resource_id.map(|s| s.to_string()),
|
||||||
|
message: message.map(|s| s.to_string()),
|
||||||
|
metadata: metadata.map(|s| s.to_string()),
|
||||||
|
ip_address: ip_address.map(|s| s.to_string()),
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
create_audit_event(pool, &event).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query audit events with filtering and pagination.
|
||||||
|
pub async fn list_audit_events(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
filter: &AuditFilter,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<AuditEvent>> {
|
||||||
|
let event_type_str = filter.event_type.map(|et| et.as_str().to_string());
|
||||||
|
let since_str = filter.since.as_ref().map(format_datetime);
|
||||||
|
let until_str = filter.until.as_ref().map(format_datetime);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, event_type, actor, resource_type, resource_id,
|
||||||
|
message, metadata, ip_address, created_at
|
||||||
|
FROM audit_events
|
||||||
|
WHERE (?1 IS NULL OR event_type = ?1)
|
||||||
|
AND (?2 IS NULL OR resource_type = ?2)
|
||||||
|
AND (?3 IS NULL OR resource_id = ?3)
|
||||||
|
AND (?4 IS NULL OR created_at >= ?4)
|
||||||
|
AND (?5 IS NULL OR created_at <= ?5)
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?6 OFFSET ?7
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(event_type_str)
|
||||||
|
.bind(&filter.resource_type)
|
||||||
|
.bind(&filter.resource_id)
|
||||||
|
.bind(since_str)
|
||||||
|
.bind(until_str)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
let id: i64 = r.try_get("id")?;
|
||||||
|
let event_type_str: String = r.try_get("event_type")?;
|
||||||
|
let actor: String = r.try_get("actor")?;
|
||||||
|
let resource_type: Option<String> = r.try_get("resource_type")?;
|
||||||
|
let resource_id: Option<String> = r.try_get("resource_id")?;
|
||||||
|
let message: Option<String> = r.try_get("message")?;
|
||||||
|
let metadata: Option<String> = r.try_get("metadata")?;
|
||||||
|
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
|
||||||
|
let event_type = AuditEventType::from_str(&event_type_str)?;
|
||||||
|
|
||||||
|
events.push(AuditEvent {
|
||||||
|
id,
|
||||||
|
event_type,
|
||||||
|
actor,
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
message,
|
||||||
|
metadata,
|
||||||
|
ip_address,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a single audit event by ID.
|
||||||
|
pub async fn get_audit_event(pool: &SqlitePool, id: i64) -> Result<Option<AuditEvent>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, event_type, actor, resource_type, resource_id,
|
||||||
|
message, metadata, ip_address, created_at
|
||||||
|
FROM audit_events
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let event_type_str: String = r.try_get("event_type")?;
|
||||||
|
let actor: String = r.try_get("actor")?;
|
||||||
|
let resource_type: Option<String> = r.try_get("resource_type")?;
|
||||||
|
let resource_id: Option<String> = r.try_get("resource_id")?;
|
||||||
|
let message: Option<String> = r.try_get("message")?;
|
||||||
|
let metadata: Option<String> = r.try_get("metadata")?;
|
||||||
|
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
|
||||||
|
let event_type = AuditEventType::from_str(&event_type_str)?;
|
||||||
|
|
||||||
|
Ok(Some(AuditEvent {
|
||||||
|
id,
|
||||||
|
event_type,
|
||||||
|
actor,
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
message,
|
||||||
|
metadata,
|
||||||
|
ip_address,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count total audit events matching a filter.
|
||||||
|
pub async fn count_audit_events(pool: &SqlitePool, filter: &AuditFilter) -> Result<i64> {
|
||||||
|
let event_type_str = filter.event_type.map(|et| et.as_str().to_string());
|
||||||
|
let since_str = filter.since.as_ref().map(format_datetime);
|
||||||
|
let until_str = filter.until.as_ref().map(format_datetime);
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM audit_events
|
||||||
|
WHERE (?1 IS NULL OR event_type = ?1)
|
||||||
|
AND (?2 IS NULL OR resource_type = ?2)
|
||||||
|
AND (?3 IS NULL OR resource_id = ?3)
|
||||||
|
AND (?4 IS NULL OR created_at >= ?4)
|
||||||
|
AND (?5 IS NULL OR created_at <= ?5)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(event_type_str)
|
||||||
|
.bind(&filter.resource_type)
|
||||||
|
.bind(&filter.resource_id)
|
||||||
|
.bind(since_str)
|
||||||
|
.bind(until_str)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let count: i64 = row.try_get("count")?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Backup metadata repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use nx9_wg_core::types::backup::BackupMeta;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Helper to convert a database row into a `BackupMeta` domain struct.
|
||||||
|
fn row_to_backup_meta(r: &sqlx::sqlite::SqliteRow) -> Result<BackupMeta> {
|
||||||
|
let id_str: String = r.try_get("id")?;
|
||||||
|
let filename: String = r.try_get("filename")?;
|
||||||
|
let size_i64: i64 = r.try_get("size")?;
|
||||||
|
let checksum: String = r.try_get("checksum")?;
|
||||||
|
let encrypted_i64: i64 = r.try_get("encrypted")?;
|
||||||
|
let schema_version: String = r.try_get("schema_version")?;
|
||||||
|
let description: Option<String> = r.try_get("description")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
|
||||||
|
let id = Uuid::parse_str(&id_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid backup UUID '{id_str}': {e}")))?;
|
||||||
|
|
||||||
|
Ok(BackupMeta {
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
size_bytes: size_i64,
|
||||||
|
checksum,
|
||||||
|
schema_version,
|
||||||
|
encrypted: encrypted_i64 != 0,
|
||||||
|
description,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record metadata for a new backup file.
|
||||||
|
pub async fn create_backup_meta(pool: &SqlitePool, meta: &BackupMeta) -> Result<()> {
|
||||||
|
let id_str = meta.id.to_string();
|
||||||
|
let created_at_str = format_datetime(&meta.created_at);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO backups (id, filename, size, checksum, encrypted, schema_version, description, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.bind(&meta.filename)
|
||||||
|
.bind(meta.size_bytes)
|
||||||
|
.bind(&meta.checksum)
|
||||||
|
.bind(if meta.encrypted { 1 } else { 0 })
|
||||||
|
.bind(&meta.schema_version)
|
||||||
|
.bind(&meta.description)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve backup metadata by UUID.
|
||||||
|
pub async fn get_backup_meta(pool: &SqlitePool, id: Uuid) -> Result<Option<BackupMeta>> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM backups WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_backup_meta(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all backup records ordered by creation date descending.
|
||||||
|
pub async fn list_backups(pool: &SqlitePool) -> Result<Vec<BackupMeta>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM backups ORDER BY created_at DESC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_backup_meta(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a backup record by UUID.
|
||||||
|
pub async fn delete_backup_meta(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let result = sqlx::query("DELETE FROM backups WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!(
|
||||||
|
"Backup record '{id_str}' not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a consistent, atomic file snapshot of the database using SQLite VACUUM INTO.
|
||||||
|
pub async fn vacuum_into(pool: &SqlitePool, target_file_path: &str) -> Result<()> {
|
||||||
|
// Check if target file already exists, remove it if so since VACUUM INTO fails if target exists
|
||||||
|
let path = std::path::Path::new(target_file_path);
|
||||||
|
if path.exists() {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent().filter(|p| !p.exists()) {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| DbError::Internal(format!("Failed to create backup directory: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite VACUUM INTO requires a string literal filename
|
||||||
|
let escaped_path = target_file_path.replace('\'', "''");
|
||||||
|
let query_str = format!("VACUUM INTO '{escaped_path}'");
|
||||||
|
sqlx::query(&query_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Client Profile repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, DeviceCategory, NatType};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Helper to convert a database row into a `ClientProfile` domain struct.
|
||||||
|
fn row_to_profile(r: &sqlx::sqlite::SqliteRow) -> Result<ClientProfile> {
|
||||||
|
let id: String = r.try_get("id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let provider: Option<String> = r.try_get("provider")?;
|
||||||
|
let device_str: Option<String> = r.try_get("device")?;
|
||||||
|
let connection_type_str: String = r.try_get("connection_type")?;
|
||||||
|
let nat_type_str: String = r.try_get("nat_type")?;
|
||||||
|
let mtu_i64: i64 = r.try_get("mtu")?;
|
||||||
|
let dns: Option<String> = r.try_get("dns")?;
|
||||||
|
let keepalive_i64: Option<i64> = r.try_get("persistent_keepalive")?;
|
||||||
|
let is_builtin_i64: i64 = r.try_get("is_builtin")?;
|
||||||
|
let description: Option<String> = r.try_get("description")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
let device = match device_str {
|
||||||
|
Some(s) if !s.trim().is_empty() => Some(
|
||||||
|
DeviceCategory::from_str(&s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid device category '{s}': {e}")))?,
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection_type = ConnectionType::from_str(&connection_type_str).map_err(|e| {
|
||||||
|
DbError::Validation(format!(
|
||||||
|
"invalid connection type '{connection_type_str}': {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let nat_type = NatType::from_str(&nat_type_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid nat type '{nat_type_str}': {e}")))?;
|
||||||
|
|
||||||
|
Ok(ClientProfile {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
device,
|
||||||
|
connection_type,
|
||||||
|
nat_type,
|
||||||
|
mtu: mtu_i64 as u16,
|
||||||
|
dns,
|
||||||
|
persistent_keepalive: keepalive_i64.map(|k| k as u16),
|
||||||
|
is_builtin: is_builtin_i64 != 0,
|
||||||
|
description,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new client profile.
|
||||||
|
pub async fn create_client_profile(pool: &SqlitePool, profile: &ClientProfile) -> Result<()> {
|
||||||
|
let now = format_datetime(&Utc::now().naive_utc());
|
||||||
|
let device_str = profile.device.map(|d| d.as_str().to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO client_profiles (
|
||||||
|
id, name, provider, device, connection_type, nat_type,
|
||||||
|
mtu, dns, persistent_keepalive, is_builtin, description,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&profile.id)
|
||||||
|
.bind(&profile.name)
|
||||||
|
.bind(&profile.provider)
|
||||||
|
.bind(&device_str)
|
||||||
|
.bind(profile.connection_type.as_str())
|
||||||
|
.bind(profile.nat_type.as_str())
|
||||||
|
.bind(profile.mtu as i64)
|
||||||
|
.bind(&profile.dns)
|
||||||
|
.bind(profile.persistent_keepalive.map(|k| k as i64))
|
||||||
|
.bind(if profile.is_builtin { 1i64 } else { 0i64 })
|
||||||
|
.bind(&profile.description)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
|
||||||
|
DbError::Conflict(format!("client profile '{}' already exists", profile.id))
|
||||||
|
}
|
||||||
|
other => DbError::Sqlx(other),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a client profile by ID.
|
||||||
|
pub async fn get_client_profile(pool: &SqlitePool, id: &str) -> Result<Option<ClientProfile>> {
|
||||||
|
let row = sqlx::query("SELECT * FROM client_profiles WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
row.map(|r| row_to_profile(&r)).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all client profiles ordered by built-in status (built-in first) then name.
|
||||||
|
pub async fn list_client_profiles(pool: &SqlitePool) -> Result<Vec<ClientProfile>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM client_profiles ORDER BY is_builtin DESC, name ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
rows.iter().map(row_to_profile).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a custom client profile. Built-in profiles cannot be modified.
|
||||||
|
pub async fn update_client_profile(pool: &SqlitePool, profile: &ClientProfile) -> Result<()> {
|
||||||
|
let existing = get_client_profile(pool, &profile.id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("client profile '{}' not found", profile.id)))?;
|
||||||
|
|
||||||
|
if existing.is_builtin {
|
||||||
|
return Err(DbError::Validation(format!(
|
||||||
|
"built-in client profile '{}' cannot be modified",
|
||||||
|
profile.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = format_datetime(&Utc::now().naive_utc());
|
||||||
|
let device_str = profile.device.map(|d| d.as_str().to_string());
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE client_profiles SET
|
||||||
|
name = ?,
|
||||||
|
provider = ?,
|
||||||
|
device = ?,
|
||||||
|
connection_type = ?,
|
||||||
|
nat_type = ?,
|
||||||
|
mtu = ?,
|
||||||
|
dns = ?,
|
||||||
|
persistent_keepalive = ?,
|
||||||
|
description = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ? AND is_builtin = 0
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&profile.name)
|
||||||
|
.bind(&profile.provider)
|
||||||
|
.bind(&device_str)
|
||||||
|
.bind(profile.connection_type.as_str())
|
||||||
|
.bind(profile.nat_type.as_str())
|
||||||
|
.bind(profile.mtu as i64)
|
||||||
|
.bind(&profile.dns)
|
||||||
|
.bind(profile.persistent_keepalive.map(|k| k as i64))
|
||||||
|
.bind(&profile.description)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&profile.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!(
|
||||||
|
"client profile '{}' not found or is built-in",
|
||||||
|
profile.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a custom client profile. Built-in profiles cannot be deleted.
|
||||||
|
pub async fn delete_client_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
let existing = get_client_profile(pool, id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("client profile '{id}' not found")))?;
|
||||||
|
|
||||||
|
if existing.is_builtin {
|
||||||
|
return Err(DbError::Validation(format!(
|
||||||
|
"built-in client profile '{id}' cannot be deleted"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM client_profiles WHERE id = ? AND is_builtin = 0")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!(
|
||||||
|
"client profile '{id}' not found or is built-in"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all distinct non-null provider identifiers.
|
||||||
|
pub async fn list_distinct_providers(pool: &SqlitePool) -> Result<Vec<String>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT DISTINCT provider FROM client_profiles WHERE provider IS NOT NULL AND provider != '' ORDER BY provider ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut providers = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
let p: Option<String> = r.try_get("provider")?;
|
||||||
|
if let Some(name) = p.filter(|s| !s.trim().is_empty() && !providers.contains(s)) {
|
||||||
|
providers.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find matching profiles from database given criteria.
|
||||||
|
pub async fn find_matching_profiles(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
provider: Option<&str>,
|
||||||
|
device: Option<DeviceCategory>,
|
||||||
|
connection: Option<ConnectionType>,
|
||||||
|
nat: Option<NatType>,
|
||||||
|
) -> Result<Vec<ClientProfile>> {
|
||||||
|
let all = list_client_profiles(pool).await?;
|
||||||
|
let mut filtered = Vec::new();
|
||||||
|
|
||||||
|
for p in all {
|
||||||
|
if let Some(req_p) = provider {
|
||||||
|
match p.provider {
|
||||||
|
Some(ref prof_p) if prof_p.eq_ignore_ascii_case(req_p) => {}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if device.is_some_and(|req_d| p.device.is_some_and(|d| d != req_d)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if connection.is_some_and(|req_c| p.connection_type != req_c) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if nat.is_some_and(|req_n| p.nat_type != NatType::Unknown && p.nat_type != req_n) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! Database error types.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Result type for database operations.
|
||||||
|
pub type Result<T> = std::result::Result<T, DbError>;
|
||||||
|
|
||||||
|
/// Database-specific errors.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
/// Entity was not found.
|
||||||
|
#[error("entity not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
/// Unique or foreign key constraint violation.
|
||||||
|
#[error("constraint violation: {0}")]
|
||||||
|
ConstraintViolation(String),
|
||||||
|
|
||||||
|
/// Conflict, e.g. entity already exists.
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
/// Validation error when converting from raw database values.
|
||||||
|
#[error("validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
/// SQLx database error.
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Sqlx(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
/// Migration failure.
|
||||||
|
#[error("migration error: {0}")]
|
||||||
|
Migration(String),
|
||||||
|
|
||||||
|
/// Internal or unexpected error.
|
||||||
|
#[error("internal database error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nx9_wg_core::error::Nx9Error> for DbError {
|
||||||
|
fn from(err: nx9_wg_core::error::Nx9Error) -> Self {
|
||||||
|
Self::Validation(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! Firewall Rule repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::firewall::{
|
||||||
|
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||||
|
};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Helper to convert a database row into a `FirewallRule` domain struct.
|
||||||
|
fn row_to_rule(r: &sqlx::sqlite::SqliteRow) -> Result<FirewallRule> {
|
||||||
|
let id_str: String = r.try_get("id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let interface_id_str: Option<String> = r.try_get("interface_id")?;
|
||||||
|
let peer_id_str: Option<String> = r.try_get("peer_id").unwrap_or(None);
|
||||||
|
let direction_str: String = r.try_get("direction")?;
|
||||||
|
let action_str: String = r.try_get("action")?;
|
||||||
|
let protocol_str: String = r.try_get("protocol")?;
|
||||||
|
let source: Option<String> = r.try_get("source")?;
|
||||||
|
let destination: Option<String> = r.try_get("destination")?;
|
||||||
|
let source_port_i64: Option<i64> = r.try_get("source_port")?;
|
||||||
|
let destination_port_i64: Option<i64> = r.try_get("destination_port")?;
|
||||||
|
let port_range: Option<String> = r.try_get("port_range").unwrap_or(None);
|
||||||
|
let priority_i64: i64 = r.try_get("priority")?;
|
||||||
|
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||||
|
let description: Option<String> = r.try_get("description")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
let id = Uuid::parse_str(&id_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid firewall rule UUID '{id_str}': {e}")))?;
|
||||||
|
|
||||||
|
let interface_id = match interface_id_str {
|
||||||
|
Some(s) => Some(
|
||||||
|
Uuid::parse_str(&s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{s}': {e}")))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let peer_id = match peer_id_str {
|
||||||
|
Some(s) => Some(
|
||||||
|
Uuid::parse_str(&s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid peer UUID '{s}': {e}")))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let direction = FirewallDirection::from_str(&direction_str)?;
|
||||||
|
let action = FirewallAction::from_str(&action_str)?;
|
||||||
|
let protocol = FirewallProtocol::from_str(&protocol_str)?;
|
||||||
|
|
||||||
|
Ok(FirewallRule {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
interface_id,
|
||||||
|
peer_id,
|
||||||
|
direction,
|
||||||
|
action,
|
||||||
|
protocol,
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
source_port: source_port_i64.map(|p| p as u16),
|
||||||
|
destination_port: destination_port_i64.map(|p| p as u16),
|
||||||
|
port_range,
|
||||||
|
priority: priority_i64 as i32,
|
||||||
|
enabled: enabled_i64 != 0,
|
||||||
|
description,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new firewall rule record.
|
||||||
|
pub async fn create_rule(pool: &SqlitePool, rule: &FirewallRule) -> Result<()> {
|
||||||
|
let id_str = rule.id.to_string();
|
||||||
|
let interface_id_str = rule.interface_id.map(|id| id.to_string());
|
||||||
|
let peer_id_str = rule.peer_id.map(|id| id.to_string());
|
||||||
|
let created_at_str = format_datetime(&rule.created_at);
|
||||||
|
let updated_at_str = format_datetime(&rule.updated_at);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO firewall_rules (
|
||||||
|
id, name, interface_id, peer_id, direction, action, protocol,
|
||||||
|
source, destination, source_port, destination_port, port_range,
|
||||||
|
priority, enabled, description, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.bind(&rule.name)
|
||||||
|
.bind(interface_id_str)
|
||||||
|
.bind(peer_id_str)
|
||||||
|
.bind(rule.direction.as_str())
|
||||||
|
.bind(rule.action.as_str())
|
||||||
|
.bind(rule.protocol.as_str())
|
||||||
|
.bind(&rule.source)
|
||||||
|
.bind(&rule.destination)
|
||||||
|
.bind(rule.source_port.map(|p| p as i64))
|
||||||
|
.bind(rule.destination_port.map(|p| p as i64))
|
||||||
|
.bind(&rule.port_range)
|
||||||
|
.bind(rule.priority as i64)
|
||||||
|
.bind(if rule.enabled { 1 } else { 0 })
|
||||||
|
.bind(&rule.description)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.bind(&updated_at_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a firewall rule by UUID.
|
||||||
|
pub async fn get_rule(pool: &SqlitePool, id: Uuid) -> Result<Option<FirewallRule>> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM firewall_rules WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_rule(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all firewall rules ordered by priority ascending.
|
||||||
|
pub async fn list_rules(pool: &SqlitePool) -> Result<Vec<FirewallRule>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM firewall_rules ORDER BY priority ASC, name ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_rule(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List firewall rules for a given interface ordered by priority ascending.
|
||||||
|
pub async fn list_rules_for_interface(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
interface_id: Uuid,
|
||||||
|
) -> Result<Vec<FirewallRule>> {
|
||||||
|
let iface_id_str = interface_id.to_string();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT * FROM firewall_rules WHERE interface_id = ? ORDER BY priority ASC, name ASC",
|
||||||
|
)
|
||||||
|
.bind(&iface_id_str)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_rule(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List firewall rules for a given peer ordered by priority ascending.
|
||||||
|
pub async fn list_rules_for_peer(pool: &SqlitePool, peer_id: Uuid) -> Result<Vec<FirewallRule>> {
|
||||||
|
let peer_id_str = peer_id.to_string();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT * FROM firewall_rules WHERE peer_id = ? ORDER BY priority ASC, name ASC",
|
||||||
|
)
|
||||||
|
.bind(&peer_id_str)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_rule(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a firewall rule record.
|
||||||
|
pub async fn update_rule(pool: &SqlitePool, rule: &FirewallRule) -> Result<()> {
|
||||||
|
let id_str = rule.id.to_string();
|
||||||
|
let interface_id_str = rule.interface_id.map(|id| id.to_string());
|
||||||
|
let peer_id_str = rule.peer_id.map(|id| id.to_string());
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE firewall_rules
|
||||||
|
SET name = ?, interface_id = ?, peer_id = ?, direction = ?, action = ?, protocol = ?,
|
||||||
|
source = ?, destination = ?, source_port = ?, destination_port = ?, port_range = ?,
|
||||||
|
priority = ?, enabled = ?, description = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&rule.name)
|
||||||
|
.bind(interface_id_str)
|
||||||
|
.bind(peer_id_str)
|
||||||
|
.bind(rule.direction.as_str())
|
||||||
|
.bind(rule.action.as_str())
|
||||||
|
.bind(rule.protocol.as_str())
|
||||||
|
.bind(&rule.source)
|
||||||
|
.bind(&rule.destination)
|
||||||
|
.bind(rule.source_port.map(|p| p as i64))
|
||||||
|
.bind(rule.destination_port.map(|p| p as i64))
|
||||||
|
.bind(&rule.port_range)
|
||||||
|
.bind(rule.priority as i64)
|
||||||
|
.bind(if rule.enabled { 1 } else { 0 })
|
||||||
|
.bind(&rule.description)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!(
|
||||||
|
"Firewall rule '{id_str}' not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a firewall rule by UUID.
|
||||||
|
pub async fn delete_rule(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let result = sqlx::query("DELETE FROM firewall_rules WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!(
|
||||||
|
"Firewall rule '{id_str}' not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable a firewall rule.
|
||||||
|
pub async fn set_rule_enabled(pool: &SqlitePool, id: Uuid, enabled: bool) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE firewall_rules
|
||||||
|
SET enabled = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(if enabled { 1 } else { 0 })
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!(
|
||||||
|
"Firewall rule '{id_str}' not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! WireGuard Interface repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Helper to convert a database row into an `Interface` domain struct.
|
||||||
|
fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
|
||||||
|
let id_str: String = r.try_get("id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let private_key_str: String = r.try_get("private_key")?;
|
||||||
|
let public_key_str: String = r.try_get("public_key")?;
|
||||||
|
let listen_port_i64: i64 = r.try_get("listen_port")?;
|
||||||
|
let ipv4_cidr_str: String = r.try_get("ipv4_cidr")?;
|
||||||
|
let ipv6_cidr_str: Option<String> = r.try_get("ipv6_cidr")?;
|
||||||
|
let mtu_i64: Option<i64> = r.try_get("mtu")?;
|
||||||
|
let dns: Option<String> = r.try_get("dns")?;
|
||||||
|
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||||
|
let pre_up: Option<String> = r.try_get("pre_up")?;
|
||||||
|
let post_up: Option<String> = r.try_get("post_up")?;
|
||||||
|
let pre_down: Option<String> = r.try_get("pre_down")?;
|
||||||
|
let post_down: Option<String> = r.try_get("post_down")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
let id = Uuid::parse_str(&id_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{id_str}': {e}")))?;
|
||||||
|
|
||||||
|
let address_v4 = IpNet::from_str(&ipv4_cidr_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid ipv4_cidr '{ipv4_cidr_str}': {e}")))?;
|
||||||
|
|
||||||
|
let address_v6 = match ipv6_cidr_str {
|
||||||
|
Some(s) => Some(
|
||||||
|
IpNet::from_str(&s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid ipv6_cidr '{s}': {e}")))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Interface {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
private_key: WireGuardPrivateKey::new(private_key_str),
|
||||||
|
public_key: WireGuardPublicKey::new(public_key_str),
|
||||||
|
listen_port: listen_port_i64 as u16,
|
||||||
|
address_v4,
|
||||||
|
address_v6,
|
||||||
|
mtu: mtu_i64.map(|m| m as u16),
|
||||||
|
dns,
|
||||||
|
enabled: enabled_i64 != 0,
|
||||||
|
pre_up,
|
||||||
|
post_up,
|
||||||
|
pre_down,
|
||||||
|
post_down,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new WireGuard interface desired configuration record.
|
||||||
|
pub async fn create_interface(pool: &SqlitePool, iface: &Interface) -> Result<()> {
|
||||||
|
let id_str = iface.id.to_string();
|
||||||
|
let ipv4_str = iface.address_v4.to_string();
|
||||||
|
let ipv6_str = iface.address_v6.as_ref().map(|ip| ip.to_string());
|
||||||
|
let created_at_str = format_datetime(&iface.created_at);
|
||||||
|
let updated_at_str = format_datetime(&iface.updated_at);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO interfaces (
|
||||||
|
id, name, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
|
||||||
|
mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.bind(&iface.name)
|
||||||
|
.bind(iface.private_key.as_str())
|
||||||
|
.bind(iface.public_key.as_str())
|
||||||
|
.bind(iface.listen_port as i64)
|
||||||
|
.bind(&ipv4_str)
|
||||||
|
.bind(ipv6_str)
|
||||||
|
.bind(iface.mtu.map(|m| m as i64))
|
||||||
|
.bind(&iface.dns)
|
||||||
|
.bind(if iface.enabled { 1 } else { 0 })
|
||||||
|
.bind(&iface.pre_up)
|
||||||
|
.bind(&iface.post_up)
|
||||||
|
.bind(&iface.pre_down)
|
||||||
|
.bind(&iface.post_down)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.bind(&updated_at_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => DbError::Conflict(format!(
|
||||||
|
"Interface with name '{}' already exists",
|
||||||
|
iface.name
|
||||||
|
)),
|
||||||
|
_ => DbError::Sqlx(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve an interface by its UUID.
|
||||||
|
pub async fn get_interface(pool: &SqlitePool, id: Uuid) -> Result<Option<Interface>> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM interfaces WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_interface(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve an interface by its name.
|
||||||
|
pub async fn get_interface_by_name(pool: &SqlitePool, name: &str) -> Result<Option<Interface>> {
|
||||||
|
let row = sqlx::query("SELECT * FROM interfaces WHERE name = ?")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_interface(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all interfaces.
|
||||||
|
pub async fn list_interfaces(pool: &SqlitePool) -> Result<Vec<Interface>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM interfaces ORDER BY name ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_interface(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an interface record.
|
||||||
|
pub async fn update_interface(pool: &SqlitePool, iface: &Interface) -> Result<()> {
|
||||||
|
let id_str = iface.id.to_string();
|
||||||
|
let ipv4_str = iface.address_v4.to_string();
|
||||||
|
let ipv6_str = iface.address_v6.as_ref().map(|ip| ip.to_string());
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE interfaces
|
||||||
|
SET name = ?, private_key = ?, public_key = ?, listen_port = ?,
|
||||||
|
ipv4_cidr = ?, ipv6_cidr = ?, mtu = ?, dns = ?, enabled = ?,
|
||||||
|
pre_up = ?, post_up = ?, pre_down = ?, post_down = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&iface.name)
|
||||||
|
.bind(iface.private_key.as_str())
|
||||||
|
.bind(iface.public_key.as_str())
|
||||||
|
.bind(iface.listen_port as i64)
|
||||||
|
.bind(&ipv4_str)
|
||||||
|
.bind(ipv6_str)
|
||||||
|
.bind(iface.mtu.map(|m| m as i64))
|
||||||
|
.bind(&iface.dns)
|
||||||
|
.bind(if iface.enabled { 1 } else { 0 })
|
||||||
|
.bind(&iface.pre_up)
|
||||||
|
.bind(&iface.post_up)
|
||||||
|
.bind(&iface.pre_down)
|
||||||
|
.bind(&iface.post_down)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Interface '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an interface by UUID. Peers are deleted automatically via ON DELETE CASCADE.
|
||||||
|
pub async fn delete_interface(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let result = sqlx::query("DELETE FROM interfaces WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Interface '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable an interface.
|
||||||
|
pub async fn set_interface_enabled(pool: &SqlitePool, id: Uuid, enabled: bool) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE interfaces
|
||||||
|
SET enabled = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(if enabled { 1 } else { 0 })
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Interface '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! SQLite persistence layer for nx9-wg.
|
||||||
|
//!
|
||||||
|
//! Provides the authoritative desired-state storage, administrator identity,
|
||||||
|
//! authentication state, interfaces, peers, networks, routes, firewall rules,
|
||||||
|
//! system settings, backup metadata, and audit records.
|
||||||
|
|
||||||
|
pub mod admin;
|
||||||
|
pub mod audit;
|
||||||
|
pub mod backups;
|
||||||
|
pub mod client_profiles;
|
||||||
|
pub mod error;
|
||||||
|
pub mod firewall;
|
||||||
|
pub mod interfaces;
|
||||||
|
pub mod login_attempts;
|
||||||
|
pub mod migrations;
|
||||||
|
pub mod models;
|
||||||
|
pub mod networks;
|
||||||
|
pub mod peers;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod sessions;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod store;
|
||||||
|
pub mod tokens;
|
||||||
|
|
||||||
|
pub use audit::AuditFilter;
|
||||||
|
pub use error::{DbError, Result};
|
||||||
|
pub use migrations::run_migrations;
|
||||||
|
pub use store::Store;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//! Login attempt tracking repository for brute-force protection.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use nx9_wg_core::types::auth::LoginAttempt;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
/// Record a login attempt (successful or failed).
|
||||||
|
pub async fn record_login_attempt(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
ip_address: &str,
|
||||||
|
success: bool,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO login_attempts (ip_address, attempted_at, success)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(ip_address)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(if success { 1 } else { 0 })
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(result.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count failed login attempts from a given IP address within the last `window_minutes`.
|
||||||
|
pub async fn count_recent_failed_attempts(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
ip_address: &str,
|
||||||
|
window_minutes: i64,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let cutoff = Utc::now().naive_utc() - Duration::minutes(window_minutes);
|
||||||
|
let cutoff_str = format_datetime(&cutoff);
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM login_attempts
|
||||||
|
WHERE ip_address = ? AND success = 0 AND attempted_at >= ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(ip_address)
|
||||||
|
.bind(&cutoff_str)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let count: i64 = row.try_get("count")?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear login attempts for an IP (e.g. after successful login).
|
||||||
|
pub async fn clear_login_attempts(pool: &SqlitePool, ip_address: &str) -> Result<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM login_attempts WHERE ip_address = ?")
|
||||||
|
.bind(ip_address)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge old login attempts older than `retention_hours`.
|
||||||
|
pub async fn purge_old_login_attempts(pool: &SqlitePool, retention_hours: i64) -> Result<u64> {
|
||||||
|
let cutoff = Utc::now().naive_utc() - Duration::hours(retention_hours);
|
||||||
|
let cutoff_str = format_datetime(&cutoff);
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM login_attempts WHERE attempted_at < ?")
|
||||||
|
.bind(&cutoff_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List recent login attempts for diagnostics.
|
||||||
|
pub async fn list_recent_login_attempts(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<LoginAttempt>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, ip_address, attempted_at, success
|
||||||
|
FROM login_attempts
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
let id: i64 = r.try_get("id")?;
|
||||||
|
let ip_address: String = r.try_get("ip_address")?;
|
||||||
|
let attempted_at_str: String = r.try_get("attempted_at")?;
|
||||||
|
let success_i64: i64 = r.try_get("success")?;
|
||||||
|
|
||||||
|
list.push(LoginAttempt {
|
||||||
|
id,
|
||||||
|
ip_address,
|
||||||
|
attempted_at: parse_datetime(&attempted_at_str)?,
|
||||||
|
success: success_i64 != 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Database migration runner.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
/// Embed migrations from the `migrations` directory.
|
||||||
|
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||||
|
|
||||||
|
/// Run all pending SQLite database migrations.
|
||||||
|
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||||
|
MIGRATOR
|
||||||
|
.run(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbError::Migration(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! Database row models and conversion utilities.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Parse a string into a `NaiveDateTime` supporting multiple common SQLite date formats.
|
||||||
|
pub fn parse_datetime(s: &str) -> Result<NaiveDateTime> {
|
||||||
|
// Try standard formats: "YYYY-MM-DD HH:MM:SS", "YYYY-MM-DDTHH:MM:SS", RFC3339
|
||||||
|
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
|
||||||
|
return Ok(dt);
|
||||||
|
}
|
||||||
|
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
|
||||||
|
return Ok(dt);
|
||||||
|
}
|
||||||
|
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
|
||||||
|
return Ok(dt);
|
||||||
|
}
|
||||||
|
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||||
|
return Ok(dt);
|
||||||
|
}
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||||
|
return Ok(dt.naive_utc());
|
||||||
|
}
|
||||||
|
NaiveDateTime::from_str(s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid datetime string '{s}': {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a `NaiveDateTime` to standard SQLite string format: "YYYY-MM-DD HH:MM:SS".
|
||||||
|
pub fn format_datetime(dt: &NaiveDateTime) -> String {
|
||||||
|
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Network repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::types::network::Network;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Helper to convert a database row into a `Network` domain struct.
|
||||||
|
fn row_to_network(r: &sqlx::sqlite::SqliteRow) -> Result<Network> {
|
||||||
|
let id_str: String = r.try_get("id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let cidr_str: String = r.try_get("cidr")?;
|
||||||
|
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||||
|
let description: Option<String> = r.try_get("description")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
let id = Uuid::parse_str(&id_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid network UUID '{id_str}': {e}")))?;
|
||||||
|
|
||||||
|
let cidr = IpNet::from_str(&cidr_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid network CIDR '{cidr_str}': {e}")))?;
|
||||||
|
|
||||||
|
Ok(Network {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
cidr,
|
||||||
|
enabled: enabled_i64 != 0,
|
||||||
|
description,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new network record.
|
||||||
|
pub async fn create_network(pool: &SqlitePool, net: &Network) -> Result<()> {
|
||||||
|
let id_str = net.id.to_string();
|
||||||
|
let cidr_str = net.cidr.to_string();
|
||||||
|
let created_at_str = format_datetime(&net.created_at);
|
||||||
|
let updated_at_str = format_datetime(&net.updated_at);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO networks (id, name, cidr, enabled, description, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.bind(&net.name)
|
||||||
|
.bind(&cidr_str)
|
||||||
|
.bind(if net.enabled { 1 } else { 0 })
|
||||||
|
.bind(&net.description)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.bind(&updated_at_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||||
|
DbError::Conflict(format!("Network with name '{}' already exists", net.name))
|
||||||
|
}
|
||||||
|
_ => DbError::Sqlx(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a network by UUID.
|
||||||
|
pub async fn get_network(pool: &SqlitePool, id: Uuid) -> Result<Option<Network>> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM networks WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_network(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a network by name.
|
||||||
|
pub async fn get_network_by_name(pool: &SqlitePool, name: &str) -> Result<Option<Network>> {
|
||||||
|
let row = sqlx::query("SELECT * FROM networks WHERE name = ?")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_network(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all networks.
|
||||||
|
pub async fn list_networks(pool: &SqlitePool) -> Result<Vec<Network>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM networks ORDER BY name ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_network(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a network record.
|
||||||
|
pub async fn update_network(pool: &SqlitePool, net: &Network) -> Result<()> {
|
||||||
|
let id_str = net.id.to_string();
|
||||||
|
let cidr_str = net.cidr.to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE networks
|
||||||
|
SET name = ?, cidr = ?, enabled = ?, description = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&net.name)
|
||||||
|
.bind(&cidr_str)
|
||||||
|
.bind(if net.enabled { 1 } else { 0 })
|
||||||
|
.bind(&net.description)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Network '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a network by UUID.
|
||||||
|
pub async fn delete_network(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let result = sqlx::query("DELETE FROM networks WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Network '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
//! WireGuard Peer repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::types::wireguard::{
|
||||||
|
Peer, PeerProfile, PeerState, PeerType, WireGuardPresharedKey, WireGuardPrivateKey,
|
||||||
|
WireGuardPublicKey,
|
||||||
|
};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Helper to convert a database row into a `Peer` domain struct.
|
||||||
|
fn row_to_peer(r: &sqlx::sqlite::SqliteRow) -> Result<Peer> {
|
||||||
|
let id_str: String = r.try_get("id")?;
|
||||||
|
let interface_id_str: String = r.try_get("interface_id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let peer_type_str: String = r.try_get("peer_type")?;
|
||||||
|
let state_str: String = r.try_get("state")?;
|
||||||
|
let profile_str: String = r.try_get("profile")?;
|
||||||
|
let public_key_str: String = r.try_get("public_key")?;
|
||||||
|
let private_key_str: Option<String> = r.try_get("private_key")?;
|
||||||
|
let preshared_key_str: Option<String> = r.try_get("preshared_key")?;
|
||||||
|
let endpoint: Option<String> = r.try_get("endpoint")?;
|
||||||
|
let allowed_ips: String = r.try_get("allowed_ips")?;
|
||||||
|
let server_allowed_ips: Option<String> = r.try_get("server_allowed_ips")?;
|
||||||
|
let address_ipv4_str: Option<String> = r.try_get("address_ipv4")?;
|
||||||
|
let address_ipv6_str: Option<String> = r.try_get("address_ipv6")?;
|
||||||
|
let dns: Option<String> = r.try_get("dns")?;
|
||||||
|
let mtu_i64: Option<i64> = r.try_get("mtu")?;
|
||||||
|
let persistent_keepalive_i64: Option<i64> = r.try_get("persistent_keepalive")?;
|
||||||
|
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||||
|
let last_handshake_at_str: Option<String> = r.try_get("last_handshake_at")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
let id = Uuid::parse_str(&id_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid peer UUID '{id_str}': {e}")))?;
|
||||||
|
|
||||||
|
let interface_id = Uuid::parse_str(&interface_id_str).map_err(|e| {
|
||||||
|
DbError::Validation(format!("invalid interface UUID '{interface_id_str}': {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let peer_type = PeerType::from_str(&peer_type_str)?;
|
||||||
|
let state = PeerState::from_str(&state_str)?;
|
||||||
|
let profile = PeerProfile::from_str(&profile_str)?;
|
||||||
|
|
||||||
|
let address_v4 =
|
||||||
|
match address_ipv4_str {
|
||||||
|
Some(s) => Some(IpNet::from_str(&s).map_err(|e| {
|
||||||
|
DbError::Validation(format!("invalid peer address_ipv4 '{s}': {e}"))
|
||||||
|
})?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let address_v6 =
|
||||||
|
match address_ipv6_str {
|
||||||
|
Some(s) => Some(IpNet::from_str(&s).map_err(|e| {
|
||||||
|
DbError::Validation(format!("invalid peer address_ipv6 '{s}': {e}"))
|
||||||
|
})?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let expires_at = match expires_at_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let last_handshake_at = match last_handshake_at_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Peer {
|
||||||
|
id,
|
||||||
|
interface_id,
|
||||||
|
name,
|
||||||
|
peer_type,
|
||||||
|
state,
|
||||||
|
public_key: WireGuardPublicKey::new(public_key_str),
|
||||||
|
private_key: private_key_str.map(WireGuardPrivateKey::new),
|
||||||
|
preshared_key: preshared_key_str.map(WireGuardPresharedKey::new),
|
||||||
|
endpoint,
|
||||||
|
allowed_ips,
|
||||||
|
server_allowed_ips,
|
||||||
|
address_v4,
|
||||||
|
address_v6,
|
||||||
|
dns,
|
||||||
|
mtu: mtu_i64.map(|m| m as u16),
|
||||||
|
persistent_keepalive: persistent_keepalive_i64.map(|k| k as u16),
|
||||||
|
profile,
|
||||||
|
expires_at,
|
||||||
|
last_handshake_at,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new WireGuard peer record.
|
||||||
|
pub async fn create_peer(pool: &SqlitePool, peer: &Peer) -> Result<()> {
|
||||||
|
let id_str = peer.id.to_string();
|
||||||
|
let interface_id_str = peer.interface_id.to_string();
|
||||||
|
let ipv4_str = peer.address_v4.as_ref().map(|ip| ip.to_string());
|
||||||
|
let ipv6_str = peer.address_v6.as_ref().map(|ip| ip.to_string());
|
||||||
|
let expires_at_str = peer.expires_at.as_ref().map(format_datetime);
|
||||||
|
let last_handshake_str = peer.last_handshake_at.as_ref().map(format_datetime);
|
||||||
|
let created_at_str = format_datetime(&peer.created_at);
|
||||||
|
let updated_at_str = format_datetime(&peer.updated_at);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO peers (
|
||||||
|
id, interface_id, name, peer_type, state, profile, public_key, private_key, preshared_key,
|
||||||
|
endpoint, allowed_ips, server_allowed_ips, address_ipv4, address_ipv6, dns, mtu,
|
||||||
|
persistent_keepalive, expires_at, last_handshake_at, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.bind(&interface_id_str)
|
||||||
|
.bind(&peer.name)
|
||||||
|
.bind(peer.peer_type.as_str())
|
||||||
|
.bind(peer.state.as_str())
|
||||||
|
.bind(peer.profile.as_str())
|
||||||
|
.bind(peer.public_key.as_str())
|
||||||
|
.bind(peer.private_key.as_ref().map(|pk| pk.as_str()))
|
||||||
|
.bind(peer.preshared_key.as_ref().map(|psk| psk.as_str()))
|
||||||
|
.bind(&peer.endpoint)
|
||||||
|
.bind(&peer.allowed_ips)
|
||||||
|
.bind(&peer.server_allowed_ips)
|
||||||
|
.bind(ipv4_str)
|
||||||
|
.bind(ipv6_str)
|
||||||
|
.bind(&peer.dns)
|
||||||
|
.bind(peer.mtu.map(|m| m as i64))
|
||||||
|
.bind(peer.persistent_keepalive.map(|k| k as i64))
|
||||||
|
.bind(expires_at_str)
|
||||||
|
.bind(last_handshake_str)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.bind(&updated_at_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||||
|
DbError::Conflict(format!("Peer with name '{}' or public key already exists for this interface", peer.name))
|
||||||
|
}
|
||||||
|
sqlx::Error::Database(dbe) if dbe.is_foreign_key_violation() => {
|
||||||
|
DbError::ConstraintViolation(format!("Referenced interface '{}' does not exist", peer.interface_id))
|
||||||
|
}
|
||||||
|
_ => DbError::Sqlx(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a peer by its UUID.
|
||||||
|
pub async fn get_peer(pool: &SqlitePool, id: Uuid) -> Result<Option<Peer>> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM peers WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_peer(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a peer by name within an interface.
|
||||||
|
pub async fn get_peer_by_name(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
interface_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Peer>> {
|
||||||
|
let iface_id_str = interface_id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM peers WHERE interface_id = ? AND name = ?")
|
||||||
|
.bind(&iface_id_str)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_peer(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a peer by public key within an interface.
|
||||||
|
pub async fn get_peer_by_public_key(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
interface_id: Uuid,
|
||||||
|
public_key: &str,
|
||||||
|
) -> Result<Option<Peer>> {
|
||||||
|
let iface_id_str = interface_id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM peers WHERE interface_id = ? AND public_key = ?")
|
||||||
|
.bind(&iface_id_str)
|
||||||
|
.bind(public_key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_peer(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all peers for a given interface.
|
||||||
|
pub async fn list_peers_for_interface(pool: &SqlitePool, interface_id: Uuid) -> Result<Vec<Peer>> {
|
||||||
|
let iface_id_str = interface_id.to_string();
|
||||||
|
let rows = sqlx::query("SELECT * FROM peers WHERE interface_id = ? ORDER BY name ASC")
|
||||||
|
.bind(&iface_id_str)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_peer(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all peers across all interfaces.
|
||||||
|
pub async fn list_all_peers(pool: &SqlitePool) -> Result<Vec<Peer>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM peers ORDER BY name ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_peer(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a peer record.
|
||||||
|
pub async fn update_peer(pool: &SqlitePool, peer: &Peer) -> Result<()> {
|
||||||
|
let id_str = peer.id.to_string();
|
||||||
|
let interface_id_str = peer.interface_id.to_string();
|
||||||
|
let ipv4_str = peer.address_v4.as_ref().map(|ip| ip.to_string());
|
||||||
|
let ipv6_str = peer.address_v6.as_ref().map(|ip| ip.to_string());
|
||||||
|
let expires_at_str = peer.expires_at.as_ref().map(format_datetime);
|
||||||
|
let last_handshake_str = peer.last_handshake_at.as_ref().map(format_datetime);
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE peers
|
||||||
|
SET interface_id = ?, name = ?, peer_type = ?, state = ?, profile = ?,
|
||||||
|
public_key = ?, private_key = ?, preshared_key = ?, endpoint = ?,
|
||||||
|
allowed_ips = ?, server_allowed_ips = ?, address_ipv4 = ?, address_ipv6 = ?,
|
||||||
|
dns = ?, mtu = ?, persistent_keepalive = ?, expires_at = ?,
|
||||||
|
last_handshake_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&interface_id_str)
|
||||||
|
.bind(&peer.name)
|
||||||
|
.bind(peer.peer_type.as_str())
|
||||||
|
.bind(peer.state.as_str())
|
||||||
|
.bind(peer.profile.as_str())
|
||||||
|
.bind(peer.public_key.as_str())
|
||||||
|
.bind(peer.private_key.as_ref().map(|pk| pk.as_str()))
|
||||||
|
.bind(peer.preshared_key.as_ref().map(|psk| psk.as_str()))
|
||||||
|
.bind(&peer.endpoint)
|
||||||
|
.bind(&peer.allowed_ips)
|
||||||
|
.bind(&peer.server_allowed_ips)
|
||||||
|
.bind(ipv4_str)
|
||||||
|
.bind(ipv6_str)
|
||||||
|
.bind(&peer.dns)
|
||||||
|
.bind(peer.mtu.map(|m| m as i64))
|
||||||
|
.bind(peer.persistent_keepalive.map(|k| k as i64))
|
||||||
|
.bind(expires_at_str)
|
||||||
|
.bind(last_handshake_str)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update peer state (active, disabled, revoked, expired).
|
||||||
|
pub async fn set_peer_state(pool: &SqlitePool, id: Uuid, state: PeerState) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE peers
|
||||||
|
SET state = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(state.as_str())
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update operational last_handshake_at timestamp.
|
||||||
|
pub async fn update_peer_handshake(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: Uuid,
|
||||||
|
handshake_at: NaiveDateTime,
|
||||||
|
) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let handshake_str = format_datetime(&handshake_at);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE peers
|
||||||
|
SET last_handshake_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&handshake_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.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.
|
||||||
|
pub async fn delete_peer(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let result = sqlx::query("DELETE FROM peers WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve all allocated IP addresses (CIDR strings) for an interface or across all interfaces.
|
||||||
|
pub async fn get_allocated_ips(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
interface_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let rows = match interface_id {
|
||||||
|
Some(iface_id) => {
|
||||||
|
let iface_id_str = iface_id.to_string();
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT address_ipv4, address_ipv6
|
||||||
|
FROM peers
|
||||||
|
WHERE interface_id = ? AND state != 'revoked'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&iface_id_str)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?
|
||||||
|
}
|
||||||
|
None => sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT address_ipv4, address_ipv6
|
||||||
|
FROM peers
|
||||||
|
WHERE state != 'revoked'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut allocated = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
let v4: Option<String> = r.try_get("address_ipv4")?;
|
||||||
|
let v6: Option<String> = r.try_get("address_ipv6")?;
|
||||||
|
if let Some(ip) = v4.as_ref().filter(|s| !s.trim().is_empty()) {
|
||||||
|
allocated.push(ip.clone());
|
||||||
|
}
|
||||||
|
if let Some(ip) = v6.as_ref().filter(|s| !s.trim().is_empty()) {
|
||||||
|
allocated.push(ip.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(allocated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find active peers whose expiration timestamp has passed.
|
||||||
|
pub async fn get_expired_active_peers(pool: &SqlitePool, now: NaiveDateTime) -> Result<Vec<Peer>> {
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM peers
|
||||||
|
WHERE state = 'active' AND expires_at IS NOT NULL AND expires_at <= ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&now_str)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_peer(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a peer as expired.
|
||||||
|
pub async fn mark_peer_expired(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
|
set_peer_state(pool, id, PeerState::Expired).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//! Route repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::types::network::Route;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Helper to convert a database row into a `Route` domain struct.
|
||||||
|
fn row_to_route(r: &sqlx::sqlite::SqliteRow) -> Result<Route> {
|
||||||
|
let id_str: String = r.try_get("id")?;
|
||||||
|
let network_id_str: Option<String> = r.try_get("network_id")?;
|
||||||
|
let interface_id_str: Option<String> = r.try_get("interface_id")?;
|
||||||
|
let destination_str: String = r.try_get("destination")?;
|
||||||
|
let gateway_str: Option<String> = r.try_get("gateway")?;
|
||||||
|
let metric_i64: Option<i64> = r.try_get("metric")?;
|
||||||
|
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||||
|
let description: Option<String> = r.try_get("description")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
let id = Uuid::parse_str(&id_str)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid route UUID '{id_str}': {e}")))?;
|
||||||
|
|
||||||
|
let network_id = match network_id_str {
|
||||||
|
Some(s) => Some(
|
||||||
|
Uuid::parse_str(&s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid network UUID '{s}': {e}")))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let interface_id = match interface_id_str {
|
||||||
|
Some(s) => Some(
|
||||||
|
Uuid::parse_str(&s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{s}': {e}")))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let destination = IpNet::from_str(&destination_str).map_err(|e| {
|
||||||
|
DbError::Validation(format!("invalid destination CIDR '{destination_str}': {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let gateway = match gateway_str {
|
||||||
|
Some(s) => Some(
|
||||||
|
IpAddr::from_str(&s)
|
||||||
|
.map_err(|e| DbError::Validation(format!("invalid gateway IP '{s}': {e}")))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Route {
|
||||||
|
id,
|
||||||
|
network_id,
|
||||||
|
interface_id,
|
||||||
|
destination,
|
||||||
|
gateway,
|
||||||
|
interface_name: None,
|
||||||
|
metric: metric_i64.map(|m| m as u32),
|
||||||
|
enabled: enabled_i64 != 0,
|
||||||
|
description,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new route record.
|
||||||
|
pub async fn create_route(pool: &SqlitePool, route: &Route) -> Result<()> {
|
||||||
|
let id_str = route.id.to_string();
|
||||||
|
let network_id_str = route.network_id.map(|id| id.to_string());
|
||||||
|
let interface_id_str = route.interface_id.map(|id| id.to_string());
|
||||||
|
let dest_str = route.destination.to_string();
|
||||||
|
let gateway_str = route.gateway.map(|g| g.to_string());
|
||||||
|
let created_at_str = format_datetime(&route.created_at);
|
||||||
|
let updated_at_str = format_datetime(&route.updated_at);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO routes (
|
||||||
|
id, network_id, interface_id, destination, gateway,
|
||||||
|
metric, enabled, description, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&id_str)
|
||||||
|
.bind(network_id_str)
|
||||||
|
.bind(interface_id_str)
|
||||||
|
.bind(&dest_str)
|
||||||
|
.bind(gateway_str)
|
||||||
|
.bind(route.metric.map(|m| m as i64))
|
||||||
|
.bind(if route.enabled { 1 } else { 0 })
|
||||||
|
.bind(&route.description)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.bind(&updated_at_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a route by UUID.
|
||||||
|
pub async fn get_route(pool: &SqlitePool, id: Uuid) -> Result<Option<Route>> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let row = sqlx::query("SELECT * FROM routes WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(row_to_route(&r)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all routes.
|
||||||
|
pub async fn list_routes(pool: &SqlitePool) -> Result<Vec<Route>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM routes ORDER BY destination ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_route(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List routes referencing a given network.
|
||||||
|
pub async fn list_routes_for_network(pool: &SqlitePool, network_id: Uuid) -> Result<Vec<Route>> {
|
||||||
|
let net_id_str = network_id.to_string();
|
||||||
|
let rows = sqlx::query("SELECT * FROM routes WHERE network_id = ? ORDER BY destination ASC")
|
||||||
|
.bind(&net_id_str)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_route(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List routes referencing a given interface.
|
||||||
|
pub async fn list_routes_for_interface(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
interface_id: Uuid,
|
||||||
|
) -> Result<Vec<Route>> {
|
||||||
|
let iface_id_str = interface_id.to_string();
|
||||||
|
let rows = sqlx::query("SELECT * FROM routes WHERE interface_id = ? ORDER BY destination ASC")
|
||||||
|
.bind(&iface_id_str)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
list.push(row_to_route(&r)?);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a route record.
|
||||||
|
pub async fn update_route(pool: &SqlitePool, route: &Route) -> Result<()> {
|
||||||
|
let id_str = route.id.to_string();
|
||||||
|
let network_id_str = route.network_id.map(|id| id.to_string());
|
||||||
|
let interface_id_str = route.interface_id.map(|id| id.to_string());
|
||||||
|
let dest_str = route.destination.to_string();
|
||||||
|
let gateway_str = route.gateway.map(|g| g.to_string());
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE routes
|
||||||
|
SET network_id = ?, interface_id = ?, destination = ?, gateway = ?,
|
||||||
|
metric = ?, enabled = ?, description = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(network_id_str)
|
||||||
|
.bind(interface_id_str)
|
||||||
|
.bind(&dest_str)
|
||||||
|
.bind(gateway_str)
|
||||||
|
.bind(route.metric.map(|m| m as i64))
|
||||||
|
.bind(if route.enabled { 1 } else { 0 })
|
||||||
|
.bind(&route.description)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Route '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a route by UUID.
|
||||||
|
pub async fn delete_route(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let result = sqlx::query("DELETE FROM routes WHERE id = ?")
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Route '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable a route.
|
||||||
|
pub async fn set_route_enabled(pool: &SqlitePool, id: Uuid, enabled: bool) -> Result<()> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE routes
|
||||||
|
SET enabled = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(if enabled { 1 } else { 0 })
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Route '{id_str}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! Session repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::auth::Session;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
/// Create a new session.
|
||||||
|
pub async fn create_session(pool: &SqlitePool, session: &Session) -> Result<()> {
|
||||||
|
let created_at_str = format_datetime(&session.created_at);
|
||||||
|
let expires_at_str = format_datetime(&session.expires_at);
|
||||||
|
let last_seen_str = session.last_seen_at.as_ref().map(format_datetime);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO sessions (id, admin_id, ip_address, user_agent, created_at, expires_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&session.id)
|
||||||
|
.bind(session.admin_id)
|
||||||
|
.bind(&session.ip_address)
|
||||||
|
.bind(&session.user_agent)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.bind(&expires_at_str)
|
||||||
|
.bind(last_seen_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a session by its ID.
|
||||||
|
pub async fn get_session(pool: &SqlitePool, id: &str) -> Result<Option<Session>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, admin_id, ip_address, user_agent, created_at, expires_at, last_seen_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let id: String = r.try_get("id")?;
|
||||||
|
let admin_id: i64 = r.try_get("admin_id")?;
|
||||||
|
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||||
|
let user_agent: Option<String> = r.try_get("user_agent")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let expires_at_str: String = r.try_get("expires_at")?;
|
||||||
|
let last_seen_str: Option<String> = r.try_get("last_seen_at")?;
|
||||||
|
|
||||||
|
let last_seen_at = match last_seen_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(Session {
|
||||||
|
id,
|
||||||
|
admin_id,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
expires_at: parse_datetime(&expires_at_str)?,
|
||||||
|
last_seen_at,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touch a session by updating its `last_seen_at` to the current time.
|
||||||
|
pub async fn touch_session(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE sessions
|
||||||
|
SET last_seen_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("Session '{id}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a session by ID (logout).
|
||||||
|
pub async fn delete_session(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all expired sessions. Returns the count of deleted sessions.
|
||||||
|
pub async fn delete_expired_sessions(pool: &SqlitePool) -> Result<u64> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all sessions for the given administrator (e.g. after password reset).
|
||||||
|
pub async fn delete_all_admin_sessions(pool: &SqlitePool, admin_id: i64) -> Result<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE admin_id = ?")
|
||||||
|
.bind(admin_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all active and unexpired sessions.
|
||||||
|
pub async fn list_sessions(pool: &SqlitePool) -> Result<Vec<Session>> {
|
||||||
|
let rows = sqlx::query("SELECT id, admin_id, ip_address, user_agent, created_at, expires_at, last_seen_at FROM sessions ORDER BY created_at DESC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut sessions = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
let id: String = r.try_get("id")?;
|
||||||
|
let admin_id: i64 = r.try_get("admin_id")?;
|
||||||
|
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||||
|
let user_agent: Option<String> = r.try_get("user_agent")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let expires_at_str: String = r.try_get("expires_at")?;
|
||||||
|
let last_seen_str: Option<String> = r.try_get("last_seen_at")?;
|
||||||
|
|
||||||
|
let last_seen_at = match last_seen_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
sessions.push(Session {
|
||||||
|
id,
|
||||||
|
admin_id,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
expires_at: parse_datetime(&expires_at_str)?,
|
||||||
|
last_seen_at,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all sessions unconditionally.
|
||||||
|
pub async fn delete_all_sessions(pool: &SqlitePool) -> Result<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! Settings repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::settings::Setting;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
/// Retrieve a setting by its key.
|
||||||
|
pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<Setting>> {
|
||||||
|
let row = sqlx::query("SELECT key, value, is_secret, updated_at FROM settings WHERE key = ?")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let key: String = r.try_get("key")?;
|
||||||
|
let value: String = r.try_get("value")?;
|
||||||
|
let is_secret_i64: i64 = r.try_get("is_secret")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
Ok(Some(Setting {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
is_secret: is_secret_i64 != 0,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve only the string value of a setting, if present.
|
||||||
|
pub async fn get_setting_value(pool: &SqlitePool, key: &str) -> Result<Option<String>> {
|
||||||
|
let setting = get_setting(pool, key).await?;
|
||||||
|
Ok(setting.map(|s| s.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsert a setting key-value pair.
|
||||||
|
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str, is_secret: bool) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO settings (key, value, is_secret, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
is_secret = excluded.is_secret,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(value)
|
||||||
|
.bind(if is_secret { 1 } else { 0 })
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a setting by key.
|
||||||
|
pub async fn delete_setting(pool: &SqlitePool, key: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM settings WHERE key = ?")
|
||||||
|
.bind(key)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all settings.
|
||||||
|
pub async fn list_settings(pool: &SqlitePool) -> Result<Vec<Setting>> {
|
||||||
|
let rows =
|
||||||
|
sqlx::query("SELECT key, value, is_secret, updated_at FROM settings ORDER BY key ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut list = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
let key: String = r.try_get("key")?;
|
||||||
|
let value: String = r.try_get("value")?;
|
||||||
|
let is_secret_i64: i64 = r.try_get("is_secret")?;
|
||||||
|
let updated_at_str: String = r.try_get("updated_at")?;
|
||||||
|
|
||||||
|
list.push(Setting {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
is_secret: is_secret_i64 != 0,
|
||||||
|
updated_at: parse_datetime(&updated_at_str)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
//! Central database `Store` providing connection lifecycle, migrations, and repository access.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::migrations::run_migrations;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Central database store handle wrapping the SQLite connection pool.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Store {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// Connect to a SQLite database by path, ensuring directory creation and setting WAL/foreign keys.
|
||||||
|
pub async fn connect_path<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
if let Some(parent) = path
|
||||||
|
.parent()
|
||||||
|
.filter(|p| !p.as_os_str().is_empty() && !p.exists())
|
||||||
|
{
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
DbError::Internal(format!(
|
||||||
|
"failed to create database parent directory '{}': {e}",
|
||||||
|
parent.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let opts = SqliteConnectOptions::new()
|
||||||
|
.filename(path)
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.synchronous(SqliteSynchronous::Normal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_millis(5000));
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.min_connections(1)
|
||||||
|
.acquire_timeout(Duration::from_secs(10))
|
||||||
|
.connect_with(opts)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a SQLite database using a connection string URL (e.g. `sqlite:///var/lib/nx9-wg/nx9-wg.db`).
|
||||||
|
pub async fn connect(database_url: &str) -> Result<Self> {
|
||||||
|
let opts = SqliteConnectOptions::from_str(database_url)
|
||||||
|
.map_err(|e| {
|
||||||
|
DbError::Validation(format!("invalid database URL '{database_url}': {e}"))
|
||||||
|
})?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.synchronous(SqliteSynchronous::Normal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_millis(5000));
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.min_connections(1)
|
||||||
|
.acquire_timeout(Duration::from_secs(10))
|
||||||
|
.connect_with(opts)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an in-memory SQLite database store (useful for tests).
|
||||||
|
pub async fn connect_in_memory() -> Result<Self> {
|
||||||
|
let opts = SqliteConnectOptions::new()
|
||||||
|
.filename(":memory:")
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_millis(5000));
|
||||||
|
|
||||||
|
// In-memory SQLite databases require max_connections=1 so the same DB is shared across queries
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect_with(opts)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run all pending SQLx migrations.
|
||||||
|
pub async fn migrate(&self) -> Result<()> {
|
||||||
|
run_migrations(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the underlying `SqlitePool`.
|
||||||
|
pub fn pool(&self) -> &SqlitePool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check database connectivity with a simple SELECT 1 query.
|
||||||
|
pub async fn health_check(&self) -> Result<()> {
|
||||||
|
sqlx::query("SELECT 1")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the connection pool gracefully.
|
||||||
|
pub async fn close(&self) {
|
||||||
|
self.pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository convenience accessors ───────────────────────────────
|
||||||
|
|
||||||
|
// Administrator
|
||||||
|
pub async fn get_admin(&self) -> Result<Option<nx9_wg_core::types::auth::Admin>> {
|
||||||
|
crate::admin::get_admin(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_admin_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::auth::Admin>> {
|
||||||
|
crate::admin::get_admin_by_username(&self.pool, username).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_exists(&self) -> Result<bool> {
|
||||||
|
crate::admin::admin_exists(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_admin(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<nx9_wg_core::types::auth::Admin> {
|
||||||
|
crate::admin::create_admin(&self.pool, username, password_hash).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_admin_password(&self, new_password_hash: &str) -> Result<()> {
|
||||||
|
crate::admin::update_admin_password(&self.pool, new_password_hash).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_admin_totp(&self, secret: Option<&str>, enabled: bool) -> Result<()> {
|
||||||
|
crate::admin::update_admin_totp(&self.pool, secret, enabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_admin_login(&self, ip_address: Option<&str>) -> Result<()> {
|
||||||
|
crate::admin::record_admin_login(&self.pool, ip_address).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
pub async fn create_session(&self, session: &nx9_wg_core::types::auth::Session) -> Result<()> {
|
||||||
|
crate::sessions::create_session(&self.pool, session).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_session(&self, id: &str) -> Result<Option<nx9_wg_core::types::auth::Session>> {
|
||||||
|
crate::sessions::get_session(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn touch_session(&self, id: &str) -> Result<()> {
|
||||||
|
crate::sessions::touch_session(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_session(&self, id: &str) -> Result<()> {
|
||||||
|
crate::sessions::delete_session(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_expired_sessions(&self) -> Result<u64> {
|
||||||
|
crate::sessions::delete_expired_sessions(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_admin_sessions(&self, admin_id: i64) -> Result<u64> {
|
||||||
|
crate::sessions::delete_all_admin_sessions(&self.pool, admin_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_sessions(&self) -> Result<Vec<nx9_wg_core::types::auth::Session>> {
|
||||||
|
crate::sessions::list_sessions(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_sessions(&self) -> Result<u64> {
|
||||||
|
crate::sessions::delete_all_sessions(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login Attempts (Rate Limiting)
|
||||||
|
pub async fn record_login_attempt(&self, ip_address: &str, success: bool) -> Result<i64> {
|
||||||
|
crate::login_attempts::record_login_attempt(&self.pool, ip_address, success).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_recent_failed_attempts(
|
||||||
|
&self,
|
||||||
|
ip_address: &str,
|
||||||
|
window_minutes: i64,
|
||||||
|
) -> Result<i64> {
|
||||||
|
crate::login_attempts::count_recent_failed_attempts(&self.pool, ip_address, window_minutes)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_login_attempts(&self, ip_address: &str) -> Result<u64> {
|
||||||
|
crate::login_attempts::clear_login_attempts(&self.pool, ip_address).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn purge_old_login_attempts(&self, retention_hours: i64) -> Result<u64> {
|
||||||
|
crate::login_attempts::purge_old_login_attempts(&self.pool, retention_hours).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_recent_login_attempts(
|
||||||
|
&self,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::auth::LoginAttempt>> {
|
||||||
|
crate::login_attempts::list_recent_login_attempts(&self.pool, limit).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Tokens
|
||||||
|
pub async fn create_token(&self, token: &nx9_wg_core::types::auth::ApiToken) -> Result<()> {
|
||||||
|
crate::tokens::create_token(&self.pool, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tokens(&self) -> Result<Vec<nx9_wg_core::types::auth::ApiToken>> {
|
||||||
|
crate::tokens::list_tokens(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_token(&self, id: &str) -> Result<Option<nx9_wg_core::types::auth::ApiToken>> {
|
||||||
|
crate::tokens::get_token(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_token_by_hash(
|
||||||
|
&self,
|
||||||
|
hash: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::auth::ApiToken>> {
|
||||||
|
crate::tokens::find_token_by_hash(&self.pool, hash).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_token_used(&self, id: &str) -> Result<()> {
|
||||||
|
crate::tokens::mark_token_used(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_token(&self, id: &str) -> Result<()> {
|
||||||
|
crate::tokens::revoke_token(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_token(&self, id: &str) -> Result<()> {
|
||||||
|
crate::tokens::delete_token(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_expired_tokens(&self) -> Result<u64> {
|
||||||
|
crate::tokens::delete_expired_tokens(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
pub async fn create_interface(
|
||||||
|
&self,
|
||||||
|
iface: &nx9_wg_core::types::wireguard::Interface,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::interfaces::create_interface(&self.pool, iface).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_interface(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::wireguard::Interface>> {
|
||||||
|
crate::interfaces::get_interface(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_interface_by_name(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::wireguard::Interface>> {
|
||||||
|
crate::interfaces::get_interface_by_name(&self.pool, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_interfaces(&self) -> Result<Vec<nx9_wg_core::types::wireguard::Interface>> {
|
||||||
|
crate::interfaces::list_interfaces(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_interface(
|
||||||
|
&self,
|
||||||
|
iface: &nx9_wg_core::types::wireguard::Interface,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::interfaces::update_interface(&self.pool, iface).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_interface(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
|
crate::interfaces::delete_interface(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_interface_enabled(&self, id: uuid::Uuid, enabled: bool) -> Result<()> {
|
||||||
|
crate::interfaces::set_interface_enabled(&self.pool, id, enabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peers
|
||||||
|
pub async fn create_peer(&self, peer: &nx9_wg_core::types::wireguard::Peer) -> Result<()> {
|
||||||
|
crate::peers::create_peer(&self.pool, peer).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_peer(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::wireguard::Peer>> {
|
||||||
|
crate::peers::get_peer(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_peer_by_name(
|
||||||
|
&self,
|
||||||
|
iface_id: uuid::Uuid,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::wireguard::Peer>> {
|
||||||
|
crate::peers::get_peer_by_name(&self.pool, iface_id, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_peer_by_public_key(
|
||||||
|
&self,
|
||||||
|
iface_id: uuid::Uuid,
|
||||||
|
pub_key: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::wireguard::Peer>> {
|
||||||
|
crate::peers::get_peer_by_public_key(&self.pool, iface_id, pub_key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_peers_for_interface(
|
||||||
|
&self,
|
||||||
|
iface_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::wireguard::Peer>> {
|
||||||
|
crate::peers::list_peers_for_interface(&self.pool, iface_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_all_peers(&self) -> Result<Vec<nx9_wg_core::types::wireguard::Peer>> {
|
||||||
|
crate::peers::list_all_peers(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_peer(&self, peer: &nx9_wg_core::types::wireguard::Peer) -> Result<()> {
|
||||||
|
crate::peers::update_peer(&self.pool, peer).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_peer_state(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
state: nx9_wg_core::types::wireguard::PeerState,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::peers::set_peer_state(&self.pool, id, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_peer_handshake(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
handshake_at: chrono::NaiveDateTime,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::peers::update_peer_handshake(&self.pool, id, handshake_at).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_peer(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
|
crate::peers::delete_peer(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_allocated_ips(&self, interface_id: Option<uuid::Uuid>) -> Result<Vec<String>> {
|
||||||
|
crate::peers::get_allocated_ips(&self.pool, interface_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_expired_active_peers(
|
||||||
|
&self,
|
||||||
|
now: chrono::NaiveDateTime,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::wireguard::Peer>> {
|
||||||
|
crate::peers::get_expired_active_peers(&self.pool, now).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_peer_expired(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
|
crate::peers::mark_peer_expired(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Networks
|
||||||
|
pub async fn create_network(&self, net: &nx9_wg_core::types::network::Network) -> Result<()> {
|
||||||
|
crate::networks::create_network(&self.pool, net).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_network(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::network::Network>> {
|
||||||
|
crate::networks::get_network(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_network_by_name(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::network::Network>> {
|
||||||
|
crate::networks::get_network_by_name(&self.pool, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_networks(&self) -> Result<Vec<nx9_wg_core::types::network::Network>> {
|
||||||
|
crate::networks::list_networks(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_network(&self, net: &nx9_wg_core::types::network::Network) -> Result<()> {
|
||||||
|
crate::networks::update_network(&self.pool, net).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_network(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
|
crate::networks::delete_network(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
pub async fn create_route(&self, route: &nx9_wg_core::types::network::Route) -> Result<()> {
|
||||||
|
crate::routes::create_route(&self.pool, route).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_route(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::network::Route>> {
|
||||||
|
crate::routes::get_route(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_routes(&self) -> Result<Vec<nx9_wg_core::types::network::Route>> {
|
||||||
|
crate::routes::list_routes(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_routes_for_network(
|
||||||
|
&self,
|
||||||
|
network_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::network::Route>> {
|
||||||
|
crate::routes::list_routes_for_network(&self.pool, network_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_routes_for_interface(
|
||||||
|
&self,
|
||||||
|
interface_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::network::Route>> {
|
||||||
|
crate::routes::list_routes_for_interface(&self.pool, interface_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_route(&self, route: &nx9_wg_core::types::network::Route) -> Result<()> {
|
||||||
|
crate::routes::update_route(&self.pool, route).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_route(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
|
crate::routes::delete_route(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_route_enabled(&self, id: uuid::Uuid, enabled: bool) -> Result<()> {
|
||||||
|
crate::routes::set_route_enabled(&self.pool, id, enabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firewall
|
||||||
|
pub async fn create_firewall_rule(
|
||||||
|
&self,
|
||||||
|
rule: &nx9_wg_core::types::firewall::FirewallRule,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::firewall::create_rule(&self.pool, rule).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_firewall_rule(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||||
|
crate::firewall::get_rule(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_firewall_rules(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||||
|
crate::firewall::list_rules(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_firewall_rules_for_interface(
|
||||||
|
&self,
|
||||||
|
interface_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||||
|
crate::firewall::list_rules_for_interface(&self.pool, interface_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_firewall_rules_for_peer(
|
||||||
|
&self,
|
||||||
|
peer_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||||
|
crate::firewall::list_rules_for_peer(&self.pool, peer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_firewall_rule(
|
||||||
|
&self,
|
||||||
|
rule: &nx9_wg_core::types::firewall::FirewallRule,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::firewall::update_rule(&self.pool, rule).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_firewall_rule(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
|
crate::firewall::delete_rule(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_firewall_rule_enabled(&self, id: uuid::Uuid, enabled: bool) -> Result<()> {
|
||||||
|
crate::firewall::set_rule_enabled(&self.pool, id, enabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
pub async fn get_setting(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::settings::Setting>> {
|
||||||
|
crate::settings::get_setting(&self.pool, key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_setting_value(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
crate::settings::get_setting_value(&self.pool, key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_setting(&self, key: &str, value: &str, is_secret: bool) -> Result<()> {
|
||||||
|
crate::settings::set_setting(&self.pool, key, value, is_secret).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_setting(&self, key: &str) -> Result<()> {
|
||||||
|
crate::settings::delete_setting(&self.pool, key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_settings(&self) -> Result<Vec<nx9_wg_core::types::settings::Setting>> {
|
||||||
|
crate::settings::list_settings(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
pub async fn create_audit_event(
|
||||||
|
&self,
|
||||||
|
event: &nx9_wg_core::types::audit::AuditEvent,
|
||||||
|
) -> Result<i64> {
|
||||||
|
crate::audit::create_audit_event(&self.pool, event).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn record_audit(
|
||||||
|
&self,
|
||||||
|
event_type: nx9_wg_core::types::audit::AuditEventType,
|
||||||
|
actor: &str,
|
||||||
|
resource_type: Option<&str>,
|
||||||
|
resource_id: Option<&str>,
|
||||||
|
message: Option<&str>,
|
||||||
|
metadata: Option<&str>,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
) -> Result<i64> {
|
||||||
|
crate::audit::record_audit(
|
||||||
|
&self.pool,
|
||||||
|
event_type,
|
||||||
|
actor,
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
message,
|
||||||
|
metadata,
|
||||||
|
ip_address,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_audit_events(
|
||||||
|
&self,
|
||||||
|
filter: &crate::audit::AuditFilter,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::audit::AuditEvent>> {
|
||||||
|
crate::audit::list_audit_events(&self.pool, filter, limit, offset).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_audit_event(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::audit::AuditEvent>> {
|
||||||
|
crate::audit::get_audit_event(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_audit_events(&self, filter: &crate::audit::AuditFilter) -> Result<i64> {
|
||||||
|
crate::audit::count_audit_events(&self.pool, filter).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backups
|
||||||
|
pub async fn create_backup_meta(
|
||||||
|
&self,
|
||||||
|
meta: &nx9_wg_core::types::backup::BackupMeta,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::backups::create_backup_meta(&self.pool, meta).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_backup_meta(
|
||||||
|
&self,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::backup::BackupMeta>> {
|
||||||
|
crate::backups::get_backup_meta(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_backups(&self) -> Result<Vec<nx9_wg_core::types::backup::BackupMeta>> {
|
||||||
|
crate::backups::list_backups(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_backup_meta(&self, id: uuid::Uuid) -> Result<()> {
|
||||||
|
crate::backups::delete_backup_meta(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn vacuum_into(&self, target_file_path: &str) -> Result<()> {
|
||||||
|
crate::backups::vacuum_into(&self.pool, target_file_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Profiles
|
||||||
|
pub async fn create_client_profile(
|
||||||
|
&self,
|
||||||
|
profile: &nx9_wg_core::types::client_profile::ClientProfile,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::client_profiles::create_client_profile(&self.pool, profile).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_client_profile(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<Option<nx9_wg_core::types::client_profile::ClientProfile>> {
|
||||||
|
crate::client_profiles::get_client_profile(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_client_profiles(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::client_profile::ClientProfile>> {
|
||||||
|
crate::client_profiles::list_client_profiles(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_client_profile(
|
||||||
|
&self,
|
||||||
|
profile: &nx9_wg_core::types::client_profile::ClientProfile,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::client_profiles::update_client_profile(&self.pool, profile).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_client_profile(&self, id: &str) -> Result<()> {
|
||||||
|
crate::client_profiles::delete_client_profile(&self.pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_distinct_providers(&self) -> Result<Vec<String>> {
|
||||||
|
crate::client_profiles::list_distinct_providers(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_matching_client_profiles(
|
||||||
|
&self,
|
||||||
|
provider: Option<&str>,
|
||||||
|
device: Option<nx9_wg_core::types::client_profile::DeviceCategory>,
|
||||||
|
connection: Option<nx9_wg_core::types::client_profile::ConnectionType>,
|
||||||
|
nat: Option<nx9_wg_core::types::client_profile::NatType>,
|
||||||
|
) -> Result<Vec<nx9_wg_core::types::client_profile::ClientProfile>> {
|
||||||
|
crate::client_profiles::find_matching_profiles(
|
||||||
|
&self.pool, provider, device, connection, nat,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! API Token repository operations.
|
||||||
|
|
||||||
|
use crate::error::{DbError, Result};
|
||||||
|
use crate::models::{format_datetime, parse_datetime};
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::auth::ApiToken;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
/// Create a new API token record. Only the token hash is stored.
|
||||||
|
pub async fn create_token(pool: &SqlitePool, token: &ApiToken) -> Result<()> {
|
||||||
|
let created_at_str = format_datetime(&token.created_at);
|
||||||
|
let expires_at_str = token.expires_at.as_ref().map(format_datetime);
|
||||||
|
let last_used_str = token.last_used_at.as_ref().map(format_datetime);
|
||||||
|
let revoked_str = token.revoked_at.as_ref().map(format_datetime);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO api_tokens (id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&token.id)
|
||||||
|
.bind(token.admin_id)
|
||||||
|
.bind(&token.name)
|
||||||
|
.bind(&token.token_hash)
|
||||||
|
.bind(&created_at_str)
|
||||||
|
.bind(expires_at_str)
|
||||||
|
.bind(last_used_str)
|
||||||
|
.bind(revoked_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||||
|
DbError::Conflict("API token with this hash already exists".to_string())
|
||||||
|
}
|
||||||
|
_ => DbError::Sqlx(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve all API tokens.
|
||||||
|
pub async fn list_tokens(pool: &SqlitePool) -> Result<Vec<ApiToken>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at
|
||||||
|
FROM api_tokens
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
let mut tokens = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
let id: String = r.try_get("id")?;
|
||||||
|
let admin_id: i64 = r.try_get("admin_id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let token_hash: String = r.try_get("token_hash")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||||
|
let last_used_str: Option<String> = r.try_get("last_used_at")?;
|
||||||
|
let revoked_str: Option<String> = r.try_get("revoked_at")?;
|
||||||
|
|
||||||
|
let expires_at = match expires_at_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let last_used_at = match last_used_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let revoked_at = match revoked_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokens.push(ApiToken {
|
||||||
|
id,
|
||||||
|
admin_id,
|
||||||
|
name,
|
||||||
|
token_hash,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
expires_at,
|
||||||
|
last_used_at,
|
||||||
|
revoked_at,
|
||||||
|
revoked: revoked_at.is_some(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve an API token by ID.
|
||||||
|
pub async fn get_token(pool: &SqlitePool, id: &str) -> Result<Option<ApiToken>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at
|
||||||
|
FROM api_tokens
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let id: String = r.try_get("id")?;
|
||||||
|
let admin_id: i64 = r.try_get("admin_id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let token_hash: String = r.try_get("token_hash")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||||
|
let last_used_str: Option<String> = r.try_get("last_used_at")?;
|
||||||
|
let revoked_str: Option<String> = r.try_get("revoked_at")?;
|
||||||
|
|
||||||
|
let expires_at = match expires_at_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let last_used_at = match last_used_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let revoked_at = match revoked_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ApiToken {
|
||||||
|
id,
|
||||||
|
admin_id,
|
||||||
|
name,
|
||||||
|
token_hash,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
expires_at,
|
||||||
|
last_used_at,
|
||||||
|
revoked_at,
|
||||||
|
revoked: revoked_at.is_some(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up an active (non-revoked) API token by its SHA-256 hash.
|
||||||
|
pub async fn find_token_by_hash(pool: &SqlitePool, token_hash: &str) -> Result<Option<ApiToken>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at
|
||||||
|
FROM api_tokens
|
||||||
|
WHERE token_hash = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let id: String = r.try_get("id")?;
|
||||||
|
let admin_id: i64 = r.try_get("admin_id")?;
|
||||||
|
let name: String = r.try_get("name")?;
|
||||||
|
let token_hash: String = r.try_get("token_hash")?;
|
||||||
|
let created_at_str: String = r.try_get("created_at")?;
|
||||||
|
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||||
|
let last_used_str: Option<String> = r.try_get("last_used_at")?;
|
||||||
|
let revoked_str: Option<String> = r.try_get("revoked_at")?;
|
||||||
|
|
||||||
|
let expires_at = match expires_at_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let last_used_at = match last_used_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let revoked_at = match revoked_str {
|
||||||
|
Some(s) => Some(parse_datetime(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ApiToken {
|
||||||
|
id,
|
||||||
|
admin_id,
|
||||||
|
name,
|
||||||
|
token_hash,
|
||||||
|
created_at: parse_datetime(&created_at_str)?,
|
||||||
|
expires_at,
|
||||||
|
last_used_at,
|
||||||
|
revoked_at,
|
||||||
|
revoked: revoked_at.is_some(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a token as used at current timestamp.
|
||||||
|
pub async fn mark_token_used(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE api_tokens
|
||||||
|
SET last_used_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!("API token '{id}' not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke an API token.
|
||||||
|
pub async fn revoke_token(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE api_tokens
|
||||||
|
SET revoked_at = ?
|
||||||
|
WHERE id = ? AND revoked_at IS NULL
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DbError::NotFound(format!(
|
||||||
|
"API token '{id}' not found or already revoked"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an API token.
|
||||||
|
pub async fn delete_token(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM api_tokens WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete expired tokens. Returns count deleted.
|
||||||
|
pub async fn delete_expired_tokens(pool: &SqlitePool) -> Result<u64> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let now_str = format_datetime(&now);
|
||||||
|
|
||||||
|
let result =
|
||||||
|
sqlx::query("DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at < ?")
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DbError::Sqlx)?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! Tests for Administrator repository operations and security invariants.
|
||||||
|
|
||||||
|
use nx9_wg_core::crypto::{hash_password, verify_password};
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_single_identity_and_crud() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
// Initially no admin exists
|
||||||
|
assert!(!store.admin_exists().await.expect("admin_exists"));
|
||||||
|
assert!(store.get_admin().await.expect("get_admin").is_none());
|
||||||
|
|
||||||
|
// Create single admin with Argon2id hash
|
||||||
|
let password = "CorrectHorseBatteryStaple123!";
|
||||||
|
let password_hash = hash_password(password).expect("hash password");
|
||||||
|
let admin = store
|
||||||
|
.create_admin("admin", &password_hash)
|
||||||
|
.await
|
||||||
|
.expect("create_admin");
|
||||||
|
|
||||||
|
assert_eq!(admin.id, 1);
|
||||||
|
assert_eq!(admin.username, "admin");
|
||||||
|
assert!(!admin.totp_enabled);
|
||||||
|
assert!(admin.last_login_at.is_none());
|
||||||
|
|
||||||
|
// Verify admin_exists returns true
|
||||||
|
assert!(store.admin_exists().await.expect("admin_exists"));
|
||||||
|
|
||||||
|
// Verify lookup by username
|
||||||
|
let fetched = store
|
||||||
|
.get_admin_by_username("admin")
|
||||||
|
.await
|
||||||
|
.expect("get_admin_by_username")
|
||||||
|
.expect("admin found");
|
||||||
|
assert_eq!(fetched.id, 1);
|
||||||
|
assert!(verify_password(password, &fetched.password_hash).expect("verify password"));
|
||||||
|
|
||||||
|
// Reject second admin creation
|
||||||
|
let second_res = store.create_admin("admin2", "hash2").await;
|
||||||
|
assert!(second_res.is_err(), "second admin must be rejected");
|
||||||
|
|
||||||
|
// Test password change
|
||||||
|
let new_password = "NewSuperSecurePassword456!";
|
||||||
|
let new_hash = hash_password(new_password).expect("new hash");
|
||||||
|
store
|
||||||
|
.update_admin_password(&new_hash)
|
||||||
|
.await
|
||||||
|
.expect("update_admin_password");
|
||||||
|
|
||||||
|
let updated = store.get_admin().await.expect("get_admin").expect("admin");
|
||||||
|
assert!(verify_password(new_password, &updated.password_hash).expect("verify new"));
|
||||||
|
assert!(!verify_password(password, &updated.password_hash).expect("old password fails"));
|
||||||
|
|
||||||
|
// Test TOTP update
|
||||||
|
store
|
||||||
|
.update_admin_totp(Some("JBSWY3DPEHPK3PXP"), true)
|
||||||
|
.await
|
||||||
|
.expect("update_admin_totp");
|
||||||
|
let totp_admin = store.get_admin().await.expect("get_admin").expect("admin");
|
||||||
|
assert!(totp_admin.totp_enabled);
|
||||||
|
assert_eq!(totp_admin.totp_secret.as_deref(), Some("JBSWY3DPEHPK3PXP"));
|
||||||
|
|
||||||
|
// Test recording login
|
||||||
|
store
|
||||||
|
.record_admin_login(Some("192.168.1.100"))
|
||||||
|
.await
|
||||||
|
.expect("record_admin_login");
|
||||||
|
let login_admin = store.get_admin().await.expect("get_admin").expect("admin");
|
||||||
|
assert!(login_admin.last_login_at.is_some());
|
||||||
|
assert_eq!(login_admin.last_login_ip.as_deref(), Some("192.168.1.100"));
|
||||||
|
|
||||||
|
// Verify Debug formatting redacts password_hash and totp_secret
|
||||||
|
let debug_str = format!("{:?}", login_admin);
|
||||||
|
assert!(debug_str.contains("[REDACTED]"));
|
||||||
|
assert!(!debug_str.contains(password));
|
||||||
|
assert!(!debug_str.contains(new_password));
|
||||||
|
assert!(!debug_str.contains("JBSWY3DPEHPK3PXP"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! Tests for Session and API Token repository operations.
|
||||||
|
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use nx9_wg_core::crypto::{generate_api_token, hash_password};
|
||||||
|
use nx9_wg_core::types::auth::{ApiToken, Session};
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_session_lifecycle() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let pw_hash = hash_password("AdminPass123!").expect("hash");
|
||||||
|
store.create_admin("admin", &pw_hash).await.expect("admin");
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let session_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
id: session_id.clone(),
|
||||||
|
admin_id: 1,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: now + Duration::hours(24),
|
||||||
|
last_seen_at: Some(now),
|
||||||
|
ip_address: Some("10.0.0.5".to_string()),
|
||||||
|
user_agent: Some("TestAgent/1.0".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
store
|
||||||
|
.create_session(&session)
|
||||||
|
.await
|
||||||
|
.expect("create_session");
|
||||||
|
|
||||||
|
let fetched = store
|
||||||
|
.get_session(&session_id)
|
||||||
|
.await
|
||||||
|
.expect("get_session")
|
||||||
|
.expect("session found");
|
||||||
|
assert_eq!(fetched.id, session_id);
|
||||||
|
assert_eq!(fetched.admin_id, 1);
|
||||||
|
assert_eq!(fetched.ip_address.as_deref(), Some("10.0.0.5"));
|
||||||
|
|
||||||
|
// Touch session
|
||||||
|
store
|
||||||
|
.touch_session(&session_id)
|
||||||
|
.await
|
||||||
|
.expect("touch_session");
|
||||||
|
|
||||||
|
// Test delete expired sessions
|
||||||
|
let expired_id = Uuid::new_v4().to_string();
|
||||||
|
let expired_session = Session {
|
||||||
|
id: expired_id.clone(),
|
||||||
|
admin_id: 1,
|
||||||
|
created_at: now - Duration::hours(48),
|
||||||
|
expires_at: now - Duration::hours(24),
|
||||||
|
last_seen_at: None,
|
||||||
|
ip_address: None,
|
||||||
|
user_agent: None,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.create_session(&expired_session)
|
||||||
|
.await
|
||||||
|
.expect("expired session");
|
||||||
|
|
||||||
|
let deleted = store
|
||||||
|
.delete_expired_sessions()
|
||||||
|
.await
|
||||||
|
.expect("delete expired");
|
||||||
|
assert_eq!(deleted, 1);
|
||||||
|
assert!(store.get_session(&expired_id).await.expect("get").is_none());
|
||||||
|
assert!(store.get_session(&session_id).await.expect("get").is_some());
|
||||||
|
|
||||||
|
// Delete single session
|
||||||
|
store
|
||||||
|
.delete_session(&session_id)
|
||||||
|
.await
|
||||||
|
.expect("delete session");
|
||||||
|
assert!(store.get_session(&session_id).await.expect("get").is_none());
|
||||||
|
|
||||||
|
// Test delete_all_admin_sessions
|
||||||
|
let s1 = Session {
|
||||||
|
id: "s1".to_string(),
|
||||||
|
admin_id: 1,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: now + Duration::hours(1),
|
||||||
|
last_seen_at: None,
|
||||||
|
ip_address: None,
|
||||||
|
user_agent: None,
|
||||||
|
};
|
||||||
|
let s2 = Session {
|
||||||
|
id: "s2".to_string(),
|
||||||
|
admin_id: 1,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: now + Duration::hours(1),
|
||||||
|
last_seen_at: None,
|
||||||
|
ip_address: None,
|
||||||
|
user_agent: None,
|
||||||
|
};
|
||||||
|
store.create_session(&s1).await.expect("s1");
|
||||||
|
store.create_session(&s2).await.expect("s2");
|
||||||
|
|
||||||
|
let deleted_all = store
|
||||||
|
.delete_all_admin_sessions(1)
|
||||||
|
.await
|
||||||
|
.expect("delete all");
|
||||||
|
assert_eq!(deleted_all, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_api_token_lifecycle() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let pw_hash = hash_password("AdminPass123!").expect("hash");
|
||||||
|
store.create_admin("admin", &pw_hash).await.expect("admin");
|
||||||
|
|
||||||
|
let (raw_token, token_hash) = generate_api_token();
|
||||||
|
let token_id = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let token = ApiToken {
|
||||||
|
id: token_id.clone(),
|
||||||
|
admin_id: 1,
|
||||||
|
name: "CI/CD Deployment Token".to_string(),
|
||||||
|
token_hash: token_hash.clone(),
|
||||||
|
created_at: now,
|
||||||
|
expires_at: Some(now + Duration::days(30)),
|
||||||
|
last_used_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.create_token(&token).await.expect("create_token");
|
||||||
|
|
||||||
|
// Lookup by hash
|
||||||
|
let found = store
|
||||||
|
.find_token_by_hash(&token_hash)
|
||||||
|
.await
|
||||||
|
.expect("find by hash")
|
||||||
|
.expect("token found");
|
||||||
|
assert_eq!(found.id, token_id);
|
||||||
|
assert_eq!(found.name, "CI/CD Deployment Token");
|
||||||
|
assert!(!found.revoked);
|
||||||
|
|
||||||
|
// Verify raw token is never in the stored record
|
||||||
|
let debug_out = format!("{:?}", found);
|
||||||
|
assert!(debug_out.contains("[REDACTED]"));
|
||||||
|
assert!(!debug_out.contains(&raw_token));
|
||||||
|
|
||||||
|
// Mark token used
|
||||||
|
store.mark_token_used(&token_id).await.expect("mark used");
|
||||||
|
let after_use = store
|
||||||
|
.get_token(&token_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("token");
|
||||||
|
assert!(after_use.last_used_at.is_some());
|
||||||
|
|
||||||
|
// List tokens
|
||||||
|
let tokens = store.list_tokens().await.expect("list tokens");
|
||||||
|
assert_eq!(tokens.len(), 1);
|
||||||
|
|
||||||
|
// Revoke token
|
||||||
|
store.revoke_token(&token_id).await.expect("revoke token");
|
||||||
|
let revoked = store
|
||||||
|
.get_token(&token_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("token");
|
||||||
|
assert!(revoked.revoked);
|
||||||
|
assert!(revoked.revoked_at.is_some());
|
||||||
|
|
||||||
|
// Delete token
|
||||||
|
store.delete_token(&token_id).await.expect("delete token");
|
||||||
|
assert!(store.get_token(&token_id).await.expect("get").is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Tests for client profile repository operations and built-in profiles.
|
||||||
|
|
||||||
|
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, DeviceCategory, NatType};
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_client_profiles_crud_and_builtin_protection() {
|
||||||
|
let store = Store::connect_in_memory().await.unwrap();
|
||||||
|
store.migrate().await.unwrap();
|
||||||
|
|
||||||
|
// Verify built-in profiles pre-populated by migration
|
||||||
|
let profiles = store.list_client_profiles().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
profiles.len() >= 10,
|
||||||
|
"expected at least 10 built-in profiles"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check specific built-ins
|
||||||
|
let mobile = store.get_client_profile("default-mobile").await.unwrap();
|
||||||
|
assert!(mobile.is_some());
|
||||||
|
let mobile = mobile.unwrap();
|
||||||
|
assert_eq!(mobile.connection_type, ConnectionType::Mobile);
|
||||||
|
assert_eq!(mobile.mtu, 1280);
|
||||||
|
assert_eq!(mobile.persistent_keepalive, Some(25));
|
||||||
|
assert!(mobile.is_builtin);
|
||||||
|
|
||||||
|
let cgnat = store.get_client_profile("default-cgnat").await.unwrap();
|
||||||
|
assert!(cgnat.is_some());
|
||||||
|
let cgnat = cgnat.unwrap();
|
||||||
|
assert_eq!(cgnat.nat_type, NatType::Cgnat);
|
||||||
|
assert_eq!(cgnat.mtu, 1360);
|
||||||
|
|
||||||
|
// Verify built-in cannot be modified or deleted
|
||||||
|
let mut modified_builtin = mobile.clone();
|
||||||
|
modified_builtin.mtu = 1400;
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.update_client_profile(&modified_builtin)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(store.delete_client_profile("default-mobile").await.is_err());
|
||||||
|
|
||||||
|
// Create a custom profile
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
let custom = ClientProfile {
|
||||||
|
id: "office-fiber".to_string(),
|
||||||
|
name: "Office Fiber Direct".to_string(),
|
||||||
|
provider: Some("att".to_string()),
|
||||||
|
device: Some(DeviceCategory::Linux),
|
||||||
|
connection_type: ConnectionType::Wired,
|
||||||
|
nat_type: NatType::Direct,
|
||||||
|
mtu: 1420,
|
||||||
|
dns: Some("1.1.1.1, 1.0.0.1".to_string()),
|
||||||
|
persistent_keepalive: Some(15),
|
||||||
|
is_builtin: false,
|
||||||
|
description: Some("Direct fiber connection at headquarters".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.create_client_profile(&custom).await.unwrap();
|
||||||
|
|
||||||
|
let fetched = store
|
||||||
|
.get_client_profile("office-fiber")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fetched.name, "Office Fiber Direct");
|
||||||
|
assert_eq!(fetched.provider.as_deref(), Some("att"));
|
||||||
|
assert_eq!(fetched.mtu, 1420);
|
||||||
|
assert!(!fetched.is_builtin);
|
||||||
|
|
||||||
|
// Update custom profile
|
||||||
|
let mut updated = fetched.clone();
|
||||||
|
updated.description = Some("Updated headquarters fiber".to_string());
|
||||||
|
updated.mtu = 1440;
|
||||||
|
store.update_client_profile(&updated).await.unwrap();
|
||||||
|
|
||||||
|
let fetched_updated = store
|
||||||
|
.get_client_profile("office-fiber")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fetched_updated.mtu, 1440);
|
||||||
|
assert_eq!(
|
||||||
|
fetched_updated.description.as_deref(),
|
||||||
|
Some("Updated headquarters fiber")
|
||||||
|
);
|
||||||
|
|
||||||
|
// List distinct providers
|
||||||
|
let providers = store.list_distinct_providers().await.unwrap();
|
||||||
|
assert!(providers.contains(&"att".to_string()));
|
||||||
|
assert!(providers.contains(&"tmobile".to_string()));
|
||||||
|
assert!(providers.contains(&"starlink".to_string()));
|
||||||
|
|
||||||
|
// Delete custom profile
|
||||||
|
store.delete_client_profile("office-fiber").await.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_client_profile("office-fiber")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! Tests for Network, Route, and Firewall Rule repositories.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::crypto::generate_keypair;
|
||||||
|
use nx9_wg_core::types::firewall::{
|
||||||
|
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||||
|
};
|
||||||
|
use nx9_wg_core::types::network::{Network, Route};
|
||||||
|
use nx9_wg_core::types::wireguard::Interface;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_network_and_route_crud() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let net_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let net = Network {
|
||||||
|
id: net_id,
|
||||||
|
name: "Home Lab".to_string(),
|
||||||
|
cidr: IpNet::from_str("192.168.10.0/24").expect("cidr"),
|
||||||
|
enabled: true,
|
||||||
|
description: Some("Internal lab subnet".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.create_network(&net).await.expect("create_network");
|
||||||
|
|
||||||
|
let fetched_net = store
|
||||||
|
.get_network(net_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("found");
|
||||||
|
assert_eq!(fetched_net.name, "Home Lab");
|
||||||
|
assert_eq!(fetched_net.cidr.to_string(), "192.168.10.0/24");
|
||||||
|
assert!(fetched_net.enabled);
|
||||||
|
|
||||||
|
// Test routes
|
||||||
|
let route_id = Uuid::new_v4();
|
||||||
|
let route = Route {
|
||||||
|
id: route_id,
|
||||||
|
network_id: Some(net_id),
|
||||||
|
interface_id: None,
|
||||||
|
destination: IpNet::from_str("192.168.10.0/24").expect("dest cidr"),
|
||||||
|
gateway: Some(IpAddr::from_str("10.0.0.1").expect("gateway")),
|
||||||
|
interface_name: None,
|
||||||
|
metric: Some(100),
|
||||||
|
enabled: true,
|
||||||
|
description: Some("Lab route via wg gateway".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.create_route(&route).await.expect("create_route");
|
||||||
|
|
||||||
|
let fetched_route = store
|
||||||
|
.get_route(route_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("route found");
|
||||||
|
assert_eq!(fetched_route.network_id, Some(net_id));
|
||||||
|
assert_eq!(
|
||||||
|
fetched_route.gateway,
|
||||||
|
Some(IpAddr::from_str("10.0.0.1").unwrap())
|
||||||
|
);
|
||||||
|
assert_eq!(fetched_route.metric, Some(100));
|
||||||
|
|
||||||
|
// Enable/disable route
|
||||||
|
store
|
||||||
|
.set_route_enabled(route_id, false)
|
||||||
|
.await
|
||||||
|
.expect("disable");
|
||||||
|
let disabled_route = store
|
||||||
|
.get_route(route_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("route");
|
||||||
|
assert!(!disabled_route.enabled);
|
||||||
|
|
||||||
|
// List routes for network
|
||||||
|
let net_routes = store
|
||||||
|
.list_routes_for_network(net_id)
|
||||||
|
.await
|
||||||
|
.expect("list net routes");
|
||||||
|
assert_eq!(net_routes.len(), 1);
|
||||||
|
|
||||||
|
// Deleting network sets route's network_id to NULL (ON DELETE SET NULL)
|
||||||
|
store.delete_network(net_id).await.expect("delete network");
|
||||||
|
let route_after_net_delete = store
|
||||||
|
.get_route(route_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("route");
|
||||||
|
assert!(
|
||||||
|
route_after_net_delete.network_id.is_none(),
|
||||||
|
"network_id must be SET NULL when network is deleted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_firewall_rule_crud_and_priority_ordering() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let iface_id = Uuid::new_v4();
|
||||||
|
let (priv_k, pub_k) = generate_keypair();
|
||||||
|
|
||||||
|
let iface = Interface {
|
||||||
|
id: iface_id,
|
||||||
|
name: "wg0".to_string(),
|
||||||
|
private_key: priv_k,
|
||||||
|
public_key: pub_k,
|
||||||
|
listen_port: 51820,
|
||||||
|
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: None,
|
||||||
|
dns: None,
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: None,
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.create_interface(&iface)
|
||||||
|
.await
|
||||||
|
.expect("create interface");
|
||||||
|
|
||||||
|
let rule1_id = Uuid::new_v4();
|
||||||
|
let rule1 = FirewallRule {
|
||||||
|
id: rule1_id,
|
||||||
|
name: "Allow SSH".to_string(),
|
||||||
|
interface_id: Some(iface_id),
|
||||||
|
peer_id: None,
|
||||||
|
direction: FirewallDirection::In,
|
||||||
|
action: FirewallAction::Accept,
|
||||||
|
protocol: FirewallProtocol::Tcp,
|
||||||
|
source: None,
|
||||||
|
destination: None,
|
||||||
|
source_port: None,
|
||||||
|
destination_port: Some(22),
|
||||||
|
port_range: None,
|
||||||
|
priority: 50,
|
||||||
|
enabled: true,
|
||||||
|
description: Some("SSH access".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rule2_id = Uuid::new_v4();
|
||||||
|
let rule2 = FirewallRule {
|
||||||
|
id: rule2_id,
|
||||||
|
name: "Drop All Other".to_string(),
|
||||||
|
interface_id: Some(iface_id),
|
||||||
|
peer_id: None,
|
||||||
|
direction: FirewallDirection::In,
|
||||||
|
action: FirewallAction::Drop,
|
||||||
|
protocol: FirewallProtocol::Any,
|
||||||
|
source: None,
|
||||||
|
destination: None,
|
||||||
|
source_port: None,
|
||||||
|
destination_port: None,
|
||||||
|
port_range: None,
|
||||||
|
priority: 100,
|
||||||
|
enabled: true,
|
||||||
|
description: Some("Default drop".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
store
|
||||||
|
.create_firewall_rule(&rule2)
|
||||||
|
.await
|
||||||
|
.expect("create rule2");
|
||||||
|
store
|
||||||
|
.create_firewall_rule(&rule1)
|
||||||
|
.await
|
||||||
|
.expect("create rule1");
|
||||||
|
|
||||||
|
// List rules should order by priority ASC (rule1 priority 50 comes before rule2 priority 100)
|
||||||
|
let rules = store.list_firewall_rules().await.expect("list rules");
|
||||||
|
assert_eq!(rules.len(), 2);
|
||||||
|
assert_eq!(rules[0].id, rule1_id);
|
||||||
|
assert_eq!(rules[0].priority, 50);
|
||||||
|
assert_eq!(rules[1].id, rule2_id);
|
||||||
|
assert_eq!(rules[1].priority, 100);
|
||||||
|
|
||||||
|
// List rules for interface
|
||||||
|
let iface_rules = store
|
||||||
|
.list_firewall_rules_for_interface(iface_id)
|
||||||
|
.await
|
||||||
|
.expect("list iface rules");
|
||||||
|
assert_eq!(iface_rules.len(), 2);
|
||||||
|
|
||||||
|
// Enable/disable rule
|
||||||
|
store
|
||||||
|
.set_firewall_rule_enabled(rule1_id, false)
|
||||||
|
.await
|
||||||
|
.expect("disable");
|
||||||
|
let disabled = store
|
||||||
|
.get_firewall_rule(rule1_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("rule");
|
||||||
|
assert!(!disabled.enabled);
|
||||||
|
|
||||||
|
// Delete rule
|
||||||
|
store.delete_firewall_rule(rule1_id).await.expect("delete");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_firewall_rule(rule1_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Peer-specific rule with port range
|
||||||
|
let peer_id = Uuid::new_v4();
|
||||||
|
let peer = nx9_wg_core::types::wireguard::Peer {
|
||||||
|
id: peer_id,
|
||||||
|
interface_id: iface_id,
|
||||||
|
name: "test-peer-fw".to_string(),
|
||||||
|
peer_type: nx9_wg_core::types::wireguard::PeerType::RoadWarrior,
|
||||||
|
state: nx9_wg_core::types::wireguard::PeerState::Active,
|
||||||
|
public_key: nx9_wg_core::types::wireguard::WireGuardPublicKey::new(
|
||||||
|
"testpubkey12345678901234567890123456789012=".to_string(),
|
||||||
|
),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "10.0.0.2/32".to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: Some("10.0.0.2/32".parse().unwrap()),
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
profile: nx9_wg_core::types::wireguard::PeerProfile::FullTunnel,
|
||||||
|
expires_at: None,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
store.create_peer(&peer).await.expect("create peer");
|
||||||
|
|
||||||
|
let peer_rule_id = Uuid::new_v4();
|
||||||
|
let peer_rule = FirewallRule {
|
||||||
|
id: peer_rule_id,
|
||||||
|
name: "Peer Port Range Rule".to_string(),
|
||||||
|
interface_id: Some(iface_id),
|
||||||
|
peer_id: Some(peer_id),
|
||||||
|
direction: FirewallDirection::In,
|
||||||
|
action: FirewallAction::Accept,
|
||||||
|
protocol: FirewallProtocol::TcpUdp,
|
||||||
|
source: None,
|
||||||
|
destination: None,
|
||||||
|
source_port: None,
|
||||||
|
destination_port: None,
|
||||||
|
port_range: Some("8000-8100".to_string()),
|
||||||
|
priority: 25,
|
||||||
|
enabled: true,
|
||||||
|
description: Some("Custom peer range".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.create_firewall_rule(&peer_rule)
|
||||||
|
.await
|
||||||
|
.expect("create peer rule");
|
||||||
|
|
||||||
|
let peer_rules = store
|
||||||
|
.list_firewall_rules_for_peer(peer_id)
|
||||||
|
.await
|
||||||
|
.expect("list peer rules");
|
||||||
|
assert_eq!(peer_rules.len(), 1);
|
||||||
|
assert_eq!(peer_rules[0].port_range.as_deref(), Some("8000-8100"));
|
||||||
|
assert_eq!(peer_rules[0].protocol, FirewallProtocol::TcpUdp);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! Tests for Store initialization, WAL configuration, migrations, and SQLite invariants.
|
||||||
|
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use sqlx::Row;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_in_memory_store_lifecycle() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect in-memory");
|
||||||
|
store.migrate().await.expect("run migrations");
|
||||||
|
|
||||||
|
// Verify foreign keys are enabled
|
||||||
|
let row = sqlx::query("PRAGMA foreign_keys")
|
||||||
|
.fetch_one(store.pool())
|
||||||
|
.await
|
||||||
|
.expect("pragma foreign_keys");
|
||||||
|
let fk: i64 = row.get(0);
|
||||||
|
assert_eq!(fk, 1, "foreign keys must be enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_temp_file_store_wal_mode() {
|
||||||
|
let tmp = NamedTempFile::new().expect("temp file");
|
||||||
|
let path = tmp.path();
|
||||||
|
|
||||||
|
let store = Store::connect_path(path).await.expect("connect path");
|
||||||
|
store.migrate().await.expect("run migrations");
|
||||||
|
|
||||||
|
// Verify WAL mode is configured
|
||||||
|
let row = sqlx::query("PRAGMA journal_mode")
|
||||||
|
.fetch_one(store.pool())
|
||||||
|
.await
|
||||||
|
.expect("pragma journal_mode");
|
||||||
|
let mode: String = row.get(0);
|
||||||
|
assert_eq!(mode.to_lowercase(), "wal", "WAL mode must be active");
|
||||||
|
|
||||||
|
// Verify migrations table exists and records the initial migration
|
||||||
|
let migration_count_row = sqlx::query("SELECT COUNT(*) FROM _sqlx_migrations")
|
||||||
|
.fetch_one(store.pool())
|
||||||
|
.await
|
||||||
|
.expect("query migrations");
|
||||||
|
let count: i64 = migration_count_row.get(0);
|
||||||
|
assert!(count >= 1, "at least one migration should be recorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_migration_idempotence() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect in-memory");
|
||||||
|
store.migrate().await.expect("first migration run");
|
||||||
|
// Running migrate a second time must succeed idempotently
|
||||||
|
store.migrate().await.expect("second migration run");
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Tests for Settings, Audit Log, and Backup repositories.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
|
use nx9_wg_core::types::backup::BackupMeta;
|
||||||
|
use nx9_wg_db::{AuditFilter, Store};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_settings_repository() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
// Initially missing key returns None
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_setting("non_existent")
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_setting_value("non_existent")
|
||||||
|
.await
|
||||||
|
.expect("get val")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set normal setting
|
||||||
|
store
|
||||||
|
.set_setting("server_endpoint", "vpn.example.com:51820", false)
|
||||||
|
.await
|
||||||
|
.expect("set");
|
||||||
|
let ep = store
|
||||||
|
.get_setting("server_endpoint")
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("setting found");
|
||||||
|
assert_eq!(ep.value, "vpn.example.com:51820");
|
||||||
|
assert!(!ep.is_secret);
|
||||||
|
|
||||||
|
// Set secret setting
|
||||||
|
store
|
||||||
|
.set_setting("session_secret", "SuperSecretKey999", true)
|
||||||
|
.await
|
||||||
|
.expect("set secret");
|
||||||
|
let sec = store
|
||||||
|
.get_setting("session_secret")
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("setting found");
|
||||||
|
assert_eq!(sec.value, "SuperSecretKey999");
|
||||||
|
assert!(sec.is_secret);
|
||||||
|
|
||||||
|
// Verify Debug formatting of secret setting redacts value
|
||||||
|
let sec_debug = format!("{:?}", sec);
|
||||||
|
assert!(sec_debug.contains("[REDACTED]"));
|
||||||
|
assert!(!sec_debug.contains("SuperSecretKey999"));
|
||||||
|
|
||||||
|
// Upsert existing setting
|
||||||
|
store
|
||||||
|
.set_setting("server_endpoint", "vpn2.example.com:51820", false)
|
||||||
|
.await
|
||||||
|
.expect("upsert");
|
||||||
|
let ep2 = store
|
||||||
|
.get_setting_value("server_endpoint")
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("value found");
|
||||||
|
assert_eq!(ep2, "vpn2.example.com:51820");
|
||||||
|
|
||||||
|
// List settings
|
||||||
|
let all = store.list_settings().await.expect("list");
|
||||||
|
assert_eq!(all.len(), 2);
|
||||||
|
|
||||||
|
// Delete setting
|
||||||
|
store
|
||||||
|
.delete_setting("server_endpoint")
|
||||||
|
.await
|
||||||
|
.expect("delete");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_setting("server_endpoint")
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_audit_log_append_only_and_filtering() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
// Record various events
|
||||||
|
store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::Login,
|
||||||
|
"admin",
|
||||||
|
Some("session"),
|
||||||
|
Some("sess-1"),
|
||||||
|
Some("Admin login succeeded"),
|
||||||
|
None,
|
||||||
|
Some("192.168.1.50"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("record login");
|
||||||
|
|
||||||
|
store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::InterfaceCreate,
|
||||||
|
"admin",
|
||||||
|
Some("interface"),
|
||||||
|
Some("wg0"),
|
||||||
|
Some("Interface wg0 created"),
|
||||||
|
None,
|
||||||
|
Some("192.168.1.50"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("record iface create");
|
||||||
|
|
||||||
|
store
|
||||||
|
.record_audit(
|
||||||
|
AuditEventType::PeerCreate,
|
||||||
|
"admin",
|
||||||
|
Some("peer"),
|
||||||
|
Some("peer-alice"),
|
||||||
|
Some("Peer alice created"),
|
||||||
|
None,
|
||||||
|
Some("192.168.1.50"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("record peer create");
|
||||||
|
|
||||||
|
// Total count
|
||||||
|
let total = store
|
||||||
|
.count_audit_events(&AuditFilter::default())
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(total, 3);
|
||||||
|
|
||||||
|
// Filter by event_type
|
||||||
|
let login_filter = AuditFilter {
|
||||||
|
event_type: Some(AuditEventType::Login),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let login_events = store
|
||||||
|
.list_audit_events(&login_filter, 10, 0)
|
||||||
|
.await
|
||||||
|
.expect("list login");
|
||||||
|
assert_eq!(login_events.len(), 1);
|
||||||
|
assert_eq!(login_events[0].event_type, AuditEventType::Login);
|
||||||
|
|
||||||
|
// Filter by resource_type
|
||||||
|
let peer_filter = AuditFilter {
|
||||||
|
resource_type: Some("peer".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let peer_events = store
|
||||||
|
.list_audit_events(&peer_filter, 10, 0)
|
||||||
|
.await
|
||||||
|
.expect("list peer events");
|
||||||
|
assert_eq!(peer_events.len(), 1);
|
||||||
|
assert_eq!(peer_events[0].resource_id.as_deref(), Some("peer-alice"));
|
||||||
|
|
||||||
|
// Pagination test: limit 2, offset 0 -> 2 items; offset 2 -> 1 item
|
||||||
|
let page1 = store
|
||||||
|
.list_audit_events(&AuditFilter::default(), 2, 0)
|
||||||
|
.await
|
||||||
|
.expect("page1");
|
||||||
|
assert_eq!(page1.len(), 2);
|
||||||
|
|
||||||
|
let page2 = store
|
||||||
|
.list_audit_events(&AuditFilter::default(), 2, 2)
|
||||||
|
.await
|
||||||
|
.expect("page2");
|
||||||
|
assert_eq!(page2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_backup_metadata_crud() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let backup_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let meta = BackupMeta {
|
||||||
|
id: backup_id,
|
||||||
|
filename: "nx9-wg-backup-20260816.tar.gz".to_string(),
|
||||||
|
size_bytes: 1048576,
|
||||||
|
checksum: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
.to_string(),
|
||||||
|
schema_version: "1".to_string(),
|
||||||
|
encrypted: true,
|
||||||
|
description: Some("Automated nightly backup".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
store
|
||||||
|
.create_backup_meta(&meta)
|
||||||
|
.await
|
||||||
|
.expect("create_backup_meta");
|
||||||
|
|
||||||
|
let fetched = store
|
||||||
|
.get_backup_meta(backup_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.expect("backup found");
|
||||||
|
assert_eq!(fetched.filename, "nx9-wg-backup-20260816.tar.gz");
|
||||||
|
assert_eq!(fetched.size_bytes, 1048576);
|
||||||
|
assert!(fetched.encrypted);
|
||||||
|
assert_eq!(fetched.schema_version, "1");
|
||||||
|
|
||||||
|
let list = store.list_backups().await.expect("list");
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
|
||||||
|
store.delete_backup_meta(backup_id).await.expect("delete");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_backup_meta(backup_id)
|
||||||
|
.await
|
||||||
|
.expect("get")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//! Tests for WireGuard Interface and Peer repository operations.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||||
|
use nx9_wg_core::types::wireguard::{
|
||||||
|
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
|
||||||
|
};
|
||||||
|
use nx9_wg_db::Store;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_interface_and_peer_crud_and_cascade() {
|
||||||
|
let store = Store::connect_in_memory().await.expect("connect");
|
||||||
|
store.migrate().await.expect("migrate");
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let iface_id = Uuid::new_v4();
|
||||||
|
let (priv_k, pub_k) = generate_keypair();
|
||||||
|
|
||||||
|
let iface = Interface {
|
||||||
|
id: iface_id,
|
||||||
|
name: "wg0".to_string(),
|
||||||
|
private_key: priv_k.clone(),
|
||||||
|
public_key: pub_k.clone(),
|
||||||
|
listen_port: 51820,
|
||||||
|
address_v4: IpNet::from_str("10.0.0.1/24").expect("valid cidr"),
|
||||||
|
address_v6: Some(IpNet::from_str("fd00::1/64").expect("valid cidr")),
|
||||||
|
mtu: Some(1420),
|
||||||
|
dns: Some("1.1.1.1, 8.8.8.8".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: Some("iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE".to_string()),
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
store
|
||||||
|
.create_interface(&iface)
|
||||||
|
.await
|
||||||
|
.expect("create_interface");
|
||||||
|
|
||||||
|
// Lookup interface by ID and name
|
||||||
|
let fetched = store
|
||||||
|
.get_interface(iface_id)
|
||||||
|
.await
|
||||||
|
.expect("get_interface")
|
||||||
|
.expect("iface found");
|
||||||
|
assert_eq!(fetched.name, "wg0");
|
||||||
|
assert_eq!(fetched.listen_port, 51820);
|
||||||
|
assert_eq!(fetched.address_v4.to_string(), "10.0.0.1/24");
|
||||||
|
assert_eq!(fetched.mtu, Some(1420));
|
||||||
|
|
||||||
|
let by_name = store
|
||||||
|
.get_interface_by_name("wg0")
|
||||||
|
.await
|
||||||
|
.expect("get_by_name")
|
||||||
|
.expect("found");
|
||||||
|
assert_eq!(by_name.id, iface_id);
|
||||||
|
|
||||||
|
// Reject duplicate interface name
|
||||||
|
let dup_iface = Interface {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "wg0".to_string(),
|
||||||
|
private_key: priv_k.clone(),
|
||||||
|
public_key: pub_k.clone(),
|
||||||
|
listen_port: 51821,
|
||||||
|
address_v4: IpNet::from_str("10.0.1.1/24").unwrap(),
|
||||||
|
address_v6: None,
|
||||||
|
mtu: None,
|
||||||
|
dns: None,
|
||||||
|
enabled: true,
|
||||||
|
pre_up: None,
|
||||||
|
post_up: None,
|
||||||
|
pre_down: None,
|
||||||
|
post_down: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
store.create_interface(&dup_iface).await.is_err(),
|
||||||
|
"duplicate interface name must fail"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a peer
|
||||||
|
let peer_id = Uuid::new_v4();
|
||||||
|
let (peer_priv, peer_pub) = generate_keypair();
|
||||||
|
let psk = generate_preshared_key();
|
||||||
|
|
||||||
|
let peer = Peer {
|
||||||
|
id: peer_id,
|
||||||
|
interface_id: iface_id,
|
||||||
|
name: "phone-alice".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: peer_pub.clone(),
|
||||||
|
private_key: Some(peer_priv.clone()),
|
||||||
|
preshared_key: Some(psk.clone()),
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "10.0.0.2/32".to_string(),
|
||||||
|
server_allowed_ips: Some("10.0.0.2/32".to_string()),
|
||||||
|
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
|
||||||
|
address_v6: None,
|
||||||
|
dns: Some("10.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.expect("create_peer");
|
||||||
|
|
||||||
|
// Fetch peer
|
||||||
|
let fetched_peer = store
|
||||||
|
.get_peer(peer_id)
|
||||||
|
.await
|
||||||
|
.expect("get_peer")
|
||||||
|
.expect("peer found");
|
||||||
|
assert_eq!(fetched_peer.name, "phone-alice");
|
||||||
|
assert_eq!(fetched_peer.peer_type, PeerType::RoadWarrior);
|
||||||
|
assert_eq!(fetched_peer.state, PeerState::Active);
|
||||||
|
assert_eq!(fetched_peer.profile, PeerProfile::FullTunnel);
|
||||||
|
assert_eq!(fetched_peer.allowed_ips, "10.0.0.2/32");
|
||||||
|
assert_eq!(fetched_peer.persistent_keepalive, Some(25));
|
||||||
|
|
||||||
|
// Lookup peer by name and by public key
|
||||||
|
let by_pname = store
|
||||||
|
.get_peer_by_name(iface_id, "phone-alice")
|
||||||
|
.await
|
||||||
|
.expect("by name")
|
||||||
|
.expect("found");
|
||||||
|
assert_eq!(by_pname.id, peer_id);
|
||||||
|
|
||||||
|
let by_pubk = store
|
||||||
|
.get_peer_by_public_key(iface_id, peer_pub.as_str())
|
||||||
|
.await
|
||||||
|
.expect("by pubk")
|
||||||
|
.expect("found");
|
||||||
|
assert_eq!(by_pubk.id, peer_id);
|
||||||
|
|
||||||
|
// Reject duplicate peer name on same interface
|
||||||
|
let dup_pname = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: iface_id,
|
||||||
|
name: "phone-alice".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new("different_key_123=".to_string()),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "10.0.0.3/32".to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: None,
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
profile: PeerProfile::SplitTunnel,
|
||||||
|
expires_at: None,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
store.create_peer(&dup_pname).await.is_err(),
|
||||||
|
"duplicate peer name on same interface must fail"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reject peer for non-existent interface (foreign key violation)
|
||||||
|
let non_existent_iface_peer = Peer {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
interface_id: Uuid::new_v4(),
|
||||||
|
name: "orphan-peer".to_string(),
|
||||||
|
peer_type: PeerType::RoadWarrior,
|
||||||
|
state: PeerState::Active,
|
||||||
|
public_key: WireGuardPublicKey::new("orphan_key_123=".to_string()),
|
||||||
|
private_key: None,
|
||||||
|
preshared_key: None,
|
||||||
|
endpoint: None,
|
||||||
|
allowed_ips: "10.0.0.4/32".to_string(),
|
||||||
|
server_allowed_ips: None,
|
||||||
|
address_v4: None,
|
||||||
|
address_v6: None,
|
||||||
|
dns: None,
|
||||||
|
mtu: None,
|
||||||
|
persistent_keepalive: None,
|
||||||
|
profile: PeerProfile::SplitTunnel,
|
||||||
|
expires_at: None,
|
||||||
|
last_handshake_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
store.create_peer(&non_existent_iface_peer).await.is_err(),
|
||||||
|
"peer for non-existent interface must fail foreign key constraint"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test peer state transition: active -> disabled -> revoked
|
||||||
|
store
|
||||||
|
.set_peer_state(peer_id, PeerState::Disabled)
|
||||||
|
.await
|
||||||
|
.expect("set disabled");
|
||||||
|
let disabled = store.get_peer(peer_id).await.expect("get").expect("peer");
|
||||||
|
assert_eq!(disabled.state, PeerState::Disabled);
|
||||||
|
|
||||||
|
store
|
||||||
|
.set_peer_state(peer_id, PeerState::Revoked)
|
||||||
|
.await
|
||||||
|
.expect("set revoked");
|
||||||
|
let revoked = store.get_peer(peer_id).await.expect("get").expect("peer");
|
||||||
|
assert_eq!(revoked.state, PeerState::Revoked);
|
||||||
|
|
||||||
|
// Test update_peer_handshake
|
||||||
|
let handshake_time = Utc::now().naive_utc();
|
||||||
|
store
|
||||||
|
.update_peer_handshake(peer_id, handshake_time)
|
||||||
|
.await
|
||||||
|
.expect("update handshake");
|
||||||
|
let after_hs = store.get_peer(peer_id).await.expect("get").expect("peer");
|
||||||
|
assert!(after_hs.last_handshake_at.is_some());
|
||||||
|
|
||||||
|
// Test list_peers_for_interface
|
||||||
|
let peer_list = store
|
||||||
|
.list_peers_for_interface(iface_id)
|
||||||
|
.await
|
||||||
|
.expect("list peers");
|
||||||
|
assert_eq!(peer_list.len(), 1);
|
||||||
|
|
||||||
|
// Test cascade delete: deleting interface must cascade and delete its peers
|
||||||
|
store
|
||||||
|
.delete_interface(iface_id)
|
||||||
|
.await
|
||||||
|
.expect("delete interface");
|
||||||
|
assert!(store.get_interface(iface_id).await.expect("get").is_none());
|
||||||
|
assert!(
|
||||||
|
store.get_peer(peer_id).await.expect("get").is_none(),
|
||||||
|
"peer must be cascade-deleted with interface"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "nx9-wg-network"
|
||||||
|
description = "Route, firewall, and network management for nx9-wg"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nx9-wg-core.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
ipnet.workspace = true
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! Network and firewall synchronization engine.
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::forwarding::IpForwardingStatus;
|
||||||
|
use crate::nftables::NftablesRulesetBuilder;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_core::types::firewall::FirewallRule;
|
||||||
|
use nx9_wg_core::types::network::Route;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Network Engine abstraction for route table reconciliation and nftables rule synchronization.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait NetworkEngine: Send + Sync {
|
||||||
|
/// Reconcile destination routes in the kernel routing table.
|
||||||
|
async fn sync_routes(&self, routes: &[Route]) -> Result<()>;
|
||||||
|
|
||||||
|
/// Synchronize the dedicated `table inet nx9_wg` nftables ruleset and NAT masquerade.
|
||||||
|
async fn sync_firewall(
|
||||||
|
&self,
|
||||||
|
rules: &[FirewallRule],
|
||||||
|
enable_nat: bool,
|
||||||
|
wg_subnets: &[IpNet],
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
/// Inspect kernel IP packet forwarding status.
|
||||||
|
async fn get_forwarding_status(&self) -> Result<IpForwardingStatus>;
|
||||||
|
|
||||||
|
/// Get current active generated nftables ruleset.
|
||||||
|
async fn get_active_nftables_ruleset(&self) -> Result<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory simulated network engine for tests and non-root execution.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SimulatedNetworkEngine {
|
||||||
|
active_routes: Arc<RwLock<Vec<Route>>>,
|
||||||
|
active_ruleset: Arc<RwLock<String>>,
|
||||||
|
forwarding: Arc<RwLock<IpForwardingStatus>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimulatedNetworkEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
active_routes: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
active_ruleset: Arc::new(RwLock::new(String::new())),
|
||||||
|
forwarding: Arc::new(RwLock::new(IpForwardingStatus {
|
||||||
|
ipv4_enabled: true,
|
||||||
|
ipv6_enabled: true,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_forwarding_status(&self, status: IpForwardingStatus) {
|
||||||
|
let mut fw = self.forwarding.write().await;
|
||||||
|
*fw = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl NetworkEngine for SimulatedNetworkEngine {
|
||||||
|
async fn sync_routes(&self, routes: &[Route]) -> Result<()> {
|
||||||
|
let enabled_routes: Vec<Route> = routes.iter().filter(|r| r.enabled).cloned().collect();
|
||||||
|
let mut active = self.active_routes.write().await;
|
||||||
|
*active = enabled_routes;
|
||||||
|
tracing::debug!(count = active.len(), "Simulated routes synchronized");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_firewall(
|
||||||
|
&self,
|
||||||
|
rules: &[FirewallRule],
|
||||||
|
enable_nat: bool,
|
||||||
|
wg_subnets: &[IpNet],
|
||||||
|
) -> Result<()> {
|
||||||
|
let ruleset = NftablesRulesetBuilder::build(rules, enable_nat, wg_subnets);
|
||||||
|
let mut active = self.active_ruleset.write().await;
|
||||||
|
*active = ruleset;
|
||||||
|
tracing::debug!("Simulated nftables ruleset updated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_forwarding_status(&self) -> Result<IpForwardingStatus> {
|
||||||
|
let fw = self.forwarding.read().await;
|
||||||
|
Ok(*fw)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_active_nftables_ruleset(&self) -> Result<String> {
|
||||||
|
let active = self.active_ruleset.read().await;
|
||||||
|
Ok(active.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux Native Network Engine with kernel sysfs / netlink checks and fallback.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct NativeLinuxNetworkEngine {
|
||||||
|
fallback: SimulatedNetworkEngine,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeLinuxNetworkEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
fallback: SimulatedNetworkEngine::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl NetworkEngine for NativeLinuxNetworkEngine {
|
||||||
|
async fn sync_routes(&self, routes: &[Route]) -> Result<()> {
|
||||||
|
self.fallback.sync_routes(routes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_firewall(
|
||||||
|
&self,
|
||||||
|
rules: &[FirewallRule],
|
||||||
|
enable_nat: bool,
|
||||||
|
wg_subnets: &[IpNet],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.fallback
|
||||||
|
.sync_firewall(rules, enable_nat, wg_subnets)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_forwarding_status(&self) -> Result<IpForwardingStatus> {
|
||||||
|
IpForwardingStatus::detect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_active_nftables_ruleset(&self) -> Result<String> {
|
||||||
|
self.fallback.get_active_nftables_ruleset().await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//! Error types for Linux networking, routing, and nftables operations.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, NetworkError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum NetworkError {
|
||||||
|
#[error("routing error: {0}")]
|
||||||
|
Routing(String),
|
||||||
|
|
||||||
|
#[error("firewall error: {0}")]
|
||||||
|
Firewall(String),
|
||||||
|
|
||||||
|
#[error("nftables error: {0}")]
|
||||||
|
Nftables(String),
|
||||||
|
|
||||||
|
#[error("forwarding error: {0}")]
|
||||||
|
Forwarding(String),
|
||||||
|
|
||||||
|
#[error("permission denied: {0}")]
|
||||||
|
PermissionDenied(String),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("core error: {0}")]
|
||||||
|
Core(#[from] nx9_wg_core::error::Nx9Error),
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//! Linux IP packet forwarding inspection and verification.
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Status of IPv4 and IPv6 packet forwarding in the Linux kernel.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct IpForwardingStatus {
|
||||||
|
pub ipv4_enabled: bool,
|
||||||
|
pub ipv6_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IpForwardingStatus {
|
||||||
|
/// Inspect `/proc/sys/net/` sysctl values to detect current forwarding state.
|
||||||
|
pub fn detect() -> Result<Self> {
|
||||||
|
let v4_path = Path::new("/proc/sys/net/ipv4/ip_forward");
|
||||||
|
let v6_path = Path::new("/proc/sys/net/ipv6/conf/all/forwarding");
|
||||||
|
|
||||||
|
let ipv4_enabled = if v4_path.exists() {
|
||||||
|
std::fs::read_to_string(v4_path)
|
||||||
|
.map(|s| s.trim() == "1")
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
true // fallback/test assumption
|
||||||
|
};
|
||||||
|
|
||||||
|
let ipv6_enabled = if v6_path.exists() {
|
||||||
|
std::fs::read_to_string(v6_path)
|
||||||
|
.map(|s| s.trim() == "1")
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
ipv4_enabled,
|
||||||
|
ipv6_enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable IPv4 packet forwarding via `/proc/sys/net/ipv4/ip_forward`.
|
||||||
|
pub fn set_ipv4(enabled: bool) -> Result<()> {
|
||||||
|
let v4_path = Path::new("/proc/sys/net/ipv4/ip_forward");
|
||||||
|
if v4_path.exists() {
|
||||||
|
let val = if enabled { "1\n" } else { "0\n" };
|
||||||
|
std::fs::write(v4_path, val).map_err(|e| {
|
||||||
|
crate::error::NetworkError::Forwarding(format!(
|
||||||
|
"Failed to write to {}: {e}",
|
||||||
|
v4_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable IPv6 packet forwarding via `/proc/sys/net/ipv6/conf/all/forwarding`.
|
||||||
|
pub fn set_ipv6(enabled: bool) -> Result<()> {
|
||||||
|
let v6_path = Path::new("/proc/sys/net/ipv6/conf/all/forwarding");
|
||||||
|
if v6_path.exists() {
|
||||||
|
let val = if enabled { "1\n" } else { "0\n" };
|
||||||
|
std::fs::write(v6_path, val).map_err(|e| {
|
||||||
|
crate::error::NetworkError::Forwarding(format!(
|
||||||
|
"Failed to write to {}: {e}",
|
||||||
|
v6_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! Route, firewall, and network management for nx9-wg.
|
||||||
|
|
||||||
|
pub mod engine;
|
||||||
|
pub mod error;
|
||||||
|
pub mod forwarding;
|
||||||
|
pub mod nftables;
|
||||||
|
|
||||||
|
pub use engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
|
||||||
|
pub use error::{NetworkError, Result};
|
||||||
|
pub use forwarding::IpForwardingStatus;
|
||||||
|
pub use nftables::NftablesRulesetBuilder;
|
||||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user