Release v1.1.0

This commit is contained in:
thakares committed 2026-09-02 15:19:19 +05:30
1 parent 34227efd2b
commit edc710cbd2
46 files changed
+5324 -190

No files matched your search

+7 -2
View File
@@ -22,7 +22,7 @@ Every connection opened by `Store` enforces:
2. `sessions` — Admin web sessions (`ON DELETE CASCADE`).
3. `login_attempts` — IP-based login attempt tracking for brute-force rate limiting.
4. `api_tokens` — Hashed API tokens for automation (`ON DELETE CASCADE`).
5. `interfaces` — Desired WireGuard interfaces (`wg0`, `wg1`, etc.), private/public keys, listen port, IPv4/IPv6 CIDRs, MTU, DNS.
5. `interfaces` — Desired WireGuard interfaces (`wg0`, `proton0`, etc.), role (`overlay`, `upstream`), private/public keys, optional listen port (`NULL` for dynamic kernel allocation), IPv4/IPv6 CIDRs, MTU, DNS.
6. `peers` — Desired WireGuard peer definitions, classifications (`road_warrior`, `site_gateway`, `server`, `relay`), states (`active`, `disabled`, `revoked`, `expired`), profiles (`full_tunnel`, `split_tunnel`, `custom`), public/private/preshared keys, AllowedIPs, endpoints, and persistent keepalives (`ON DELETE CASCADE`).
7. `networks` — Named network CIDRs for routing and organization.
8. `routes` — Desired kernel routing rules (`ON DELETE SET NULL`).
@@ -34,7 +34,12 @@ Every connection opened by `Store` enforces:
## Migration Strategy
- Migrations are defined in `crates/nx9-wg-db/migrations/` and embedded at compile time via `sqlx::migrate!("./migrations")`.
- Migrations are defined in `crates/nx9-wg-db/migrations/` and embedded at compile time via `sqlx::migrate!("./migrations")`:
- `0001_initial_schema.sql` — Initial relational schema.
- `0002_wiregui_schema.sql` — WireGUI capabilities and profile structures.
- `0003_server_endpoint_settings.sql` — Persistent server endpoint settings.
- `0004_interface_roles.sql` — Interface roles (`overlay` and `upstream`).
- `0005_optional_listen_port.sql` — Nullable `listen_port` for ephemeral kernel port selection.
- Migrations are executed automatically via `store.migrate().await?`.
- Migrations are tracked in the `_sqlx_migrations` table for idempotency.
@@ -0,0 +1,6 @@
-- 0004_interface_roles.sql
-- Explicit WireGuard Interface Role: Overlay (primary client network) or Upstream (third-party VPN tunnel)
ALTER TABLE interfaces ADD COLUMN role TEXT NOT NULL DEFAULT 'overlay' CHECK (role IN ('overlay', 'upstream'));
UPDATE interfaces SET role = 'overlay' WHERE role IS NULL OR role = '';
@@ -0,0 +1,34 @@
------------------------------------------------------------------------
-- nx9-wg SQLite Migration (0005_optional_listen_port.sql)
-- Allow nullable listen_port for Upstream interfaces with dynamic ports
------------------------------------------------------------------------
PRAGMA foreign_keys = OFF;
CREATE TABLE interfaces_dg_tmp (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'overlay' CHECK (role IN ('overlay', 'upstream')),
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
listen_port INTEGER,
ipv4_cidr TEXT NOT NULL,
ipv6_cidr TEXT,
mtu INTEGER,
dns TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
pre_up TEXT,
post_up TEXT,
pre_down TEXT,
post_down TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO interfaces_dg_tmp (id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr, mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at)
SELECT id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr, mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at FROM interfaces;
DROP TABLE interfaces;
ALTER TABLE interfaces_dg_tmp RENAME TO interfaces;
CREATE INDEX idx_interfaces_name ON interfaces(name);
PRAGMA foreign_keys = ON;
+19 -8
View File
@@ -4,7 +4,9 @@ use crate::error::{DbError, Result};
use crate::models::{format_datetime, parse_datetime};
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
use nx9_wg_core::types::wireguard::{
Interface, InterfaceRole, WireGuardPrivateKey, WireGuardPublicKey,
};
use sqlx::{Row, SqlitePool};
use std::str::FromStr;
use uuid::Uuid;
@@ -13,9 +15,10 @@ use uuid::Uuid;
fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
let id_str: String = r.try_get("id")?;
let name: String = r.try_get("name")?;
let role_str: Option<String> = r.try_get("role").ok();
let private_key_str: String = r.try_get("private_key")?;
let public_key_str: String = r.try_get("public_key")?;
let listen_port_i64: i64 = r.try_get("listen_port")?;
let listen_port_i64: Option<i64> = r.try_get("listen_port")?;
let ipv4_cidr_str: String = r.try_get("ipv4_cidr")?;
let ipv6_cidr_str: Option<String> = r.try_get("ipv6_cidr")?;
let mtu_i64: Option<i64> = r.try_get("mtu")?;
@@ -31,6 +34,11 @@ fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
let id = Uuid::parse_str(&id_str)
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{id_str}': {e}")))?;
let role = match role_str.as_deref() {
Some("upstream") => InterfaceRole::Upstream,
_ => InterfaceRole::Overlay,
};
let address_v4 = IpNet::from_str(&ipv4_cidr_str)
.map_err(|e| DbError::Validation(format!("invalid ipv4_cidr '{ipv4_cidr_str}': {e}")))?;
@@ -45,9 +53,10 @@ fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
Ok(Interface {
id,
name,
role,
private_key: WireGuardPrivateKey::new(private_key_str),
public_key: WireGuardPublicKey::new(public_key_str),
listen_port: listen_port_i64 as u16,
listen_port: listen_port_i64.map(|p| p as u16),
address_v4,
address_v6,
mtu: mtu_i64.map(|m| m as u16),
@@ -73,17 +82,18 @@ pub async fn create_interface(pool: &SqlitePool, iface: &Interface) -> Result<()
sqlx::query(
r#"
INSERT INTO interfaces (
id, name, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
id, name, role, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&id_str)
.bind(&iface.name)
.bind(iface.role.as_str())
.bind(iface.private_key.as_str())
.bind(iface.public_key.as_str())
.bind(iface.listen_port as i64)
.bind(iface.listen_port.map(|p| p as i64))
.bind(&ipv4_str)
.bind(ipv6_str)
.bind(iface.mtu.map(|m| m as i64))
@@ -162,16 +172,17 @@ pub async fn update_interface(pool: &SqlitePool, iface: &Interface) -> Result<()
let result = sqlx::query(
r#"
UPDATE interfaces
SET name = ?, private_key = ?, public_key = ?, listen_port = ?,
SET name = ?, role = ?, private_key = ?, public_key = ?, listen_port = ?,
ipv4_cidr = ?, ipv6_cidr = ?, mtu = ?, dns = ?, enabled = ?,
pre_up = ?, post_up = ?, pre_down = ?, post_down = ?, updated_at = ?
WHERE id = ?
"#,
)
.bind(&iface.name)
.bind(iface.role.as_str())
.bind(iface.private_key.as_str())
.bind(iface.public_key.as_str())
.bind(iface.listen_port as i64)
.bind(iface.listen_port.map(|p| p as i64))
.bind(&ipv4_str)
.bind(ipv6_str)
.bind(iface.mtu.map(|m| m as i64))
@@ -7,7 +7,7 @@ use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::types::network::{Network, Route};
use nx9_wg_core::types::wireguard::Interface;
use nx9_wg_core::types::wireguard::{Interface, InterfaceRole};
use nx9_wg_db::Store;
use std::net::IpAddr;
use std::str::FromStr;
@@ -116,9 +116,10 @@ async fn test_firewall_rule_crud_and_priority_ordering() {
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_k,
public_key: pub_k,
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: None,
@@ -4,7 +4,7 @@ use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
use nx9_wg_core::types::wireguard::{
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
};
use nx9_wg_db::Store;
use std::str::FromStr;
@@ -22,9 +22,10 @@ async fn test_interface_and_peer_crud_and_cascade() {
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: 51820,
listen_port: Some(51820),
address_v4: IpNet::from_str("10.0.0.1/24").expect("valid cidr"),
address_v6: Some(IpNet::from_str("fd00::1/64").expect("valid cidr")),
mtu: Some(1420),
@@ -50,7 +51,8 @@ async fn test_interface_and_peer_crud_and_cascade() {
.expect("get_interface")
.expect("iface found");
assert_eq!(fetched.name, "wg0");
assert_eq!(fetched.listen_port, 51820);
assert_eq!(fetched.role, InterfaceRole::Overlay);
assert_eq!(fetched.listen_port, Some(51820));
assert_eq!(fetched.address_v4.to_string(), "10.0.0.1/24");
assert_eq!(fetched.mtu, Some(1420));
@@ -65,9 +67,10 @@ async fn test_interface_and_peer_crud_and_cascade() {
let dup_iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
role: InterfaceRole::Overlay,
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: 51821,
listen_port: Some(51821),
address_v4: IpNet::from_str("10.0.1.1/24").unwrap(),
address_v6: None,
mtu: None,
@@ -233,14 +236,37 @@ async fn test_interface_and_peer_crud_and_cascade() {
.expect("list peers");
assert_eq!(peer_list.len(), 1);
// Test cascade delete: deleting interface must cascade and delete its peers
// Test upstream interface with listen_port = None
let upstream_id = Uuid::new_v4();
let upstream_iface = Interface {
id: upstream_id,
name: "proton0".to_string(),
role: InterfaceRole::Upstream,
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: None,
address_v4: IpNet::from_str("10.2.0.2/32").unwrap(),
address_v6: None,
mtu: Some(1420),
dns: Some("10.2.0.1".to_string()),
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
store
.delete_interface(iface_id)
.create_interface(&upstream_iface)
.await
.expect("delete interface");
assert!(store.get_interface(iface_id).await.expect("get").is_none());
assert!(
store.get_peer(peer_id).await.expect("get").is_none(),
"peer must be cascade-deleted with interface"
);
.expect("create upstream interface");
let fetched_upstream = store
.get_interface(upstream_id)
.await
.expect("get")
.expect("upstream found");
assert_eq!(fetched_upstream.name, "proton0");
assert_eq!(fetched_upstream.role, InterfaceRole::Upstream);
assert_eq!(fetched_upstream.listen_port, None);
}