Release v1.1.0
This commit is contained in:
1 parent
34227efd2b
commit
edc710cbd2
46 files changed
+5324
-190
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user