Release v1.1.0
This commit is contained in:
1 parent
34227efd2b
commit
edc710cbd2
46 files changed
+5324
-190
No files matched your search
@@ -6,7 +6,9 @@ use ipnet::IpNet;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, ResolvedClientProfile};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use std::str::FromStr;
|
||||
use tower::ServiceExt;
|
||||
@@ -38,9 +40,10 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
||||
let interface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Comprehensive Integration test suite for Interface Lifecycle Hardening:
|
||||
//! - Interface deletion converges desired state and kernel state
|
||||
//! - wg0 protection (deletion and disabling rejected via API & CLI)
|
||||
//! - Reconciliation orphan detection and cleanup
|
||||
//! - Desired-state read failure safety guard
|
||||
//! - Interface restart lifecycle
|
||||
//! - SPA Read-Only CLI Console allowlist and safety
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::routes::cli::{ExecuteCliRequest, build_safe_argv, scrub_secrets};
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wireguard::{LiveInterfaceStats, SimulatedWireGuardEngine, WireGuardEngine};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn setup_test_context() -> (
|
||||
TempDir,
|
||||
Store,
|
||||
AppState,
|
||||
Arc<SimulatedWireGuardEngine>,
|
||||
Arc<SimulatedNetworkEngine>,
|
||||
ReconciliationEngine,
|
||||
axum::Router,
|
||||
String,
|
||||
) {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let db_path = dir.path().join("lifecycle_test.db");
|
||||
let store = Store::connect(&db_path.to_string_lossy())
|
||||
.await
|
||||
.expect("connect to db");
|
||||
store.migrate().await.expect("run migrations");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("AdminSecret123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
let state = AppState::with_engines(store.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Login to get session ID
|
||||
let login_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "AdminSecret123!"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(login_req).await.expect("login request");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let cookie_header = resp
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.expect("set-cookie")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
|
||||
|
||||
(
|
||||
dir,
|
||||
store,
|
||||
state,
|
||||
wg_engine,
|
||||
net_engine,
|
||||
reconciler,
|
||||
app,
|
||||
session_cookie,
|
||||
)
|
||||
}
|
||||
|
||||
fn fixture_interface(name: &str, v4_cidr: &str) -> Interface {
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let now = Utc::now().naive_utc();
|
||||
Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_k,
|
||||
public_key: pub_k,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr(v4_cidr).unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_peer(iface_id: Uuid, name: &str, v4_addr: &str) -> Peer {
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let now = Utc::now().naive_utc();
|
||||
Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface_id,
|
||||
name: name.to_string(),
|
||||
public_key: pub_k,
|
||||
preshared_key: None,
|
||||
private_key: Some(priv_k),
|
||||
endpoint: None,
|
||||
address_v4: Some(validate_cidr(v4_addr).unwrap()),
|
||||
address_v6: None,
|
||||
allowed_ips: "0.0.0.0/0".to_string(),
|
||||
server_allowed_ips: None,
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
persistent_keepalive: Some(25),
|
||||
mtu: Some(1420),
|
||||
state: PeerState::Active,
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
last_handshake_at: None,
|
||||
expires_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interface_delete_removes_kernel_state() {
|
||||
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create desired interface
|
||||
let iface = fixture_interface("custom0", "10.200.0.1/24");
|
||||
store
|
||||
.create_interface(&iface)
|
||||
.await
|
||||
.expect("create interface");
|
||||
|
||||
// 2. Sync to simulated kernel
|
||||
wg_engine.sync_interface(&iface, &[]).await.expect("sync");
|
||||
|
||||
// 3. Verify kernel interface exists
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"custom0".to_string()));
|
||||
|
||||
// 4. Delete via API
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{}", iface.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 5. Verify DB object removed
|
||||
let db_iface = store.get_interface(iface.id).await.unwrap();
|
||||
assert!(db_iface.is_none());
|
||||
|
||||
// 6. Verify kernel interface removed
|
||||
let live_after = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(!live_after.contains(&"custom0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wg0_deletion_rejected() {
|
||||
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create wg0 interface
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0");
|
||||
|
||||
// 2. Attempt deletion via API
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{}", wg0.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(val["error"]["message"].as_str().unwrap().contains("wg0"));
|
||||
|
||||
// 3. Confirm DB and kernel state remain intact
|
||||
let db_wg0 = store.get_interface(wg0.id).await.unwrap();
|
||||
assert!(db_wg0.is_some());
|
||||
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"wg0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wg0_disable_rejected() {
|
||||
let (_dir, store, _state, _wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create wg0 interface
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
|
||||
// 2. Attempt disable via API
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{}/disable", wg0.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
// 3. Confirm enabled remains true in DB
|
||||
let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap();
|
||||
assert!(db_wg0.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_orphan_interface_reconciliation() {
|
||||
let (_dir, store, _state, wg_engine, _net, reconciler, _app, _cookie) =
|
||||
setup_test_context().await;
|
||||
|
||||
// 1. Create desired interface wg0
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0");
|
||||
|
||||
// 2. Inject orphan kernel-only interface (e.g. proton0)
|
||||
wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "proton0".to_string(),
|
||||
public_key: "OrphanPubKey123456789012345678901234567890=".to_string(),
|
||||
listen_port: 51821,
|
||||
fwmark: 0,
|
||||
addresses: vec!["10.2.0.2/32".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
peers: vec![],
|
||||
})
|
||||
.await;
|
||||
|
||||
// 3. Verify kernel has both wg0 and proton0
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"wg0".to_string()));
|
||||
assert!(live.contains(&"proton0".to_string()));
|
||||
|
||||
// 4. Run reconciliation plan
|
||||
let plan = reconciler.plan().await.expect("plan");
|
||||
assert!(plan.has_drift);
|
||||
let orphan_action = plan
|
||||
.actions
|
||||
.iter()
|
||||
.find(|a| a.action_type == "delete_orphan_interface" && a.resource_id == "proton0");
|
||||
assert!(
|
||||
orphan_action.is_some(),
|
||||
"Expected orphan removal action for proton0"
|
||||
);
|
||||
|
||||
// 5. Run reconciliation apply
|
||||
let report = reconciler.apply().await.expect("apply");
|
||||
assert!(report.success);
|
||||
|
||||
// 6. Confirm kernel interface proton0 is removed, wg0 remains
|
||||
let live_after = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live_after.contains(&"wg0".to_string()));
|
||||
assert!(!live_after.contains(&"proton0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interface_restart_preserves_state() {
|
||||
let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await;
|
||||
|
||||
// 1. Create interface with peer
|
||||
let wg0 = fixture_interface("wg0", "10.100.0.1/24");
|
||||
store.create_interface(&wg0).await.expect("create wg0");
|
||||
let peer = fixture_peer(wg0.id, "mobile-alice", "10.100.0.5/32");
|
||||
store.create_peer(&peer).await.expect("create peer");
|
||||
|
||||
// 2. Initial sync
|
||||
wg_engine
|
||||
.sync_interface(&wg0, &[peer.clone()])
|
||||
.await
|
||||
.expect("sync");
|
||||
|
||||
// 3. Call restart API
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{}/restart", wg0.id))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 4. Verify DB object remains identical
|
||||
let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap();
|
||||
assert_eq!(db_wg0.id, wg0.id);
|
||||
assert_eq!(db_wg0.name, "wg0");
|
||||
assert_eq!(db_wg0.address_v4, wg0.address_v4);
|
||||
assert_eq!(db_wg0.public_key.as_str(), wg0.public_key.as_str());
|
||||
|
||||
// 5. Verify live kernel state converged with peer restored
|
||||
let stats = wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("wg0 stats");
|
||||
assert_eq!(stats.name, "wg0");
|
||||
assert_eq!(stats.peers.len(), 1);
|
||||
assert_eq!(stats.peers[0].public_key, peer.public_key.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reconcile_does_not_delete_on_desired_state_read_failure() {
|
||||
let (_dir, _store, state, wg_engine, net_engine, _rec, _app, _cookie) =
|
||||
setup_test_context().await;
|
||||
|
||||
// 1. Inject live interface in kernel
|
||||
wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "wg0".to_string(),
|
||||
public_key: "Wg0PubKey12345678901234567890123456789012=".to_string(),
|
||||
listen_port: 51820,
|
||||
fwmark: 0,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
peers: vec![],
|
||||
})
|
||||
.await;
|
||||
|
||||
// 2. Desired state is empty in DB
|
||||
// Reconciler should abort rather than mass-deleting live interfaces
|
||||
let reconciler =
|
||||
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let result = reconciler.apply().await;
|
||||
assert!(result.is_err(), "Expected reconciliation to abort safely");
|
||||
|
||||
// 3. Confirm live interface was NOT deleted
|
||||
let live = wg_engine.list_interfaces().await.unwrap();
|
||||
assert!(live.contains(&"wg0".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cli_console_readonly_whitelist() {
|
||||
// 1. Test allowed read-only commands
|
||||
let allowed_tests = vec![
|
||||
ExecuteCliRequest {
|
||||
command: "version".to_string(),
|
||||
subcommand: None,
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "system".to_string(),
|
||||
subcommand: Some("status".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("list".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("show".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: Some("wg0".to_string()),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "peer".to_string(),
|
||||
subcommand: Some("list".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "live".to_string(),
|
||||
subcommand: Some("interface".to_string()),
|
||||
sub_subcommand: Some("list".to_string()),
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
ExecuteCliRequest {
|
||||
command: "reconcile".to_string(),
|
||||
subcommand: Some("status".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
];
|
||||
|
||||
for req in allowed_tests {
|
||||
let argv = build_safe_argv(&req);
|
||||
assert!(
|
||||
argv.is_ok(),
|
||||
"Expected command {:?} to be allowed",
|
||||
req.command
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Test mutating commands are rejected
|
||||
let mutating_tests = vec![
|
||||
"create", "delete", "update", "set", "enable", "disable", "restart", "apply", "restore",
|
||||
"reset", "remove", "flush", "add", "sh", "bash", "sudo",
|
||||
];
|
||||
|
||||
for cmd in mutating_tests {
|
||||
let req = ExecuteCliRequest {
|
||||
command: cmd.to_string(),
|
||||
subcommand: None,
|
||||
sub_subcommand: None,
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
let argv = build_safe_argv(&req);
|
||||
assert!(
|
||||
argv.is_err(),
|
||||
"Expected mutating command '{cmd}' to be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Test shell meta characters in target are rejected
|
||||
let bad_targets = vec![
|
||||
"-option",
|
||||
"wg0; rm -rf /",
|
||||
"wg0 | ls",
|
||||
"wg0 & sleep 5",
|
||||
"wg0 `whoami`",
|
||||
"wg0 $(whoami)",
|
||||
];
|
||||
|
||||
for bad in bad_targets {
|
||||
let req = ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("show".to_string()),
|
||||
sub_subcommand: None,
|
||||
target: Some(bad.to_string()),
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
let argv = build_safe_argv(&req);
|
||||
assert!(
|
||||
argv.is_err(),
|
||||
"Expected unsafe target '{bad}' to be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Test secrets scrubbing
|
||||
let raw_text = r#"
|
||||
Interface: wg0
|
||||
PrivateKey: aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg==
|
||||
PublicKey: dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA==
|
||||
PresharedKey: c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE=
|
||||
Addresses: 10.100.0.1/24
|
||||
"#;
|
||||
|
||||
let scrubbed = scrub_secrets(raw_text);
|
||||
assert!(!scrubbed.contains("aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg=="));
|
||||
assert!(!scrubbed.contains("c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE="));
|
||||
assert!(scrubbed.contains("[REDACTED]"));
|
||||
assert!(scrubbed.contains("10.100.0.1/24"));
|
||||
assert!(scrubbed.contains("dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA=="));
|
||||
}
|
||||
@@ -16,7 +16,9 @@ use nx9_wg_core::types::firewall::{
|
||||
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||
};
|
||||
use nx9_wg_core::types::network::Route;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
@@ -59,9 +61,10 @@ async fn test_drift_matrix_peer_lifecycle() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "nx9_test0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.10.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -267,9 +270,10 @@ async fn test_restart_recovery_simulation() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "nx9_boot".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.20.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -321,9 +325,10 @@ async fn test_secret_redaction_in_reconciliation_plan_and_report() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "nx9_sec".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.30.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -363,9 +368,10 @@ async fn test_reconciliation_status_lifecycle_and_multi_cycle_idempotency() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "nx9_idem".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.50.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -512,9 +518,10 @@ async fn test_interface_address_and_mtu_drift_lifecycle() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key.clone(),
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
|
||||
address_v6: Some(validate_cidr("fd00::1/64").unwrap()),
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::Interface;
|
||||
use nx9_wg_core::types::wireguard::{Interface, InterfaceRole};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
@@ -31,9 +31,10 @@ async fn test_reconciliation_engine_drift_detection_and_apply() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -5,7 +5,10 @@ use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wireguard::SimulatedWireGuardEngine;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn setup_test_app() -> (axum::Router, String) {
|
||||
@@ -21,7 +24,11 @@ async fn setup_test_app() -> (axum::Router, String) {
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let state = AppState::new(store);
|
||||
let state = AppState::with_engines(
|
||||
store,
|
||||
Arc::new(SimulatedWireGuardEngine::new()),
|
||||
Arc::new(SimulatedNetworkEngine::new()),
|
||||
);
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Login to get session ID
|
||||
@@ -78,6 +85,7 @@ async fn test_public_health_and_version_endpoints() {
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(val["name"], "nx9-wg");
|
||||
assert_eq!(val["version"], "1.1.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -176,14 +184,54 @@ async fn test_interfaces_and_peers_rest_lifecycle() {
|
||||
let peer_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(peer_val["state"], "disabled");
|
||||
|
||||
// 6. Delete interface (cascades peer)
|
||||
let del_iface_req = Request::builder()
|
||||
// 6. Delete wg0 interface (must be rejected with 403 Forbidden)
|
||||
let del_wg0_req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(del_iface_req).await.unwrap();
|
||||
let resp = app.clone().oneshot(del_wg0_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
// 7. Restart wg0 interface (must succeed)
|
||||
let restart_wg0_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}/restart"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(restart_wg0_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 8. Create secondary interface and delete it (must succeed)
|
||||
let create_sec_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "custom0",
|
||||
"listen_port": 51822,
|
||||
"address_v4": "10.200.0.1/24"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(create_sec_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let sec_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
let sec_id = sec_val["id"].as_str().unwrap();
|
||||
|
||||
let del_sec_req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{sec_id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(del_sec_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
use nx9_wireguard::{
|
||||
@@ -48,9 +50,10 @@ async fn setup_test_context() -> (AppState, Interface, Peer, String) {
|
||||
let interface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.100.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -220,7 +223,7 @@ async fn test_learned_endpoint_and_handshake_telemetry_ingestion() {
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.as_str().to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
listen_port: iface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: live_peers,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
@@ -270,7 +273,7 @@ async fn test_peer_allowed_ips_and_keepalive_kernel_drift() {
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.as_str().to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
listen_port: iface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: drifted_peers,
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
@@ -523,7 +526,7 @@ async fn test_interface_editing_persistence_and_key_preservation() {
|
||||
// 2. Query updated interface from database
|
||||
let updated_iface = state.store.get_interface(orig_id).await.unwrap().unwrap();
|
||||
assert_eq!(updated_iface.address_v4.to_string(), "10.200.0.1/24");
|
||||
assert_eq!(updated_iface.listen_port, 51822);
|
||||
assert_eq!(updated_iface.listen_port, Some(51822));
|
||||
assert_eq!(updated_iface.mtu, Some(1360));
|
||||
assert_eq!(updated_iface.dns, Some("9.9.9.9".to_string()));
|
||||
|
||||
@@ -796,7 +799,7 @@ async fn test_peer_telemetry_enrichment_and_status_transitions() {
|
||||
let live_iface = LiveInterfaceStats {
|
||||
name: iface.name.clone(),
|
||||
public_key: iface.public_key.to_string(),
|
||||
listen_port: iface.listen_port,
|
||||
listen_port: iface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: vec![live_peer],
|
||||
addresses: vec!["10.100.0.1/24".to_string()],
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
//! Comprehensive Integration and Lifecycle Test Suite for NX9-WG Optional Upstream interfaces.
|
||||
//!
|
||||
//! Verifies:
|
||||
//! - ProtonVPN-style .conf import, parsing, validation, persistence, and kernel synchronization
|
||||
//! - wg0 overlay non-regression during all upstream operations
|
||||
//! - Upstream enable, disable, restart, and deletion lifecycles
|
||||
//! - Reconciliation engine drift detection, convergence, and orphan cleanup
|
||||
//! - Zero secret leakage across API preview, import, status, list, and CLI
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
|
||||
use nx9_wg_api::collect_managed_wg_subnets;
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::routes::cli::{ExecuteCliRequest, build_safe_argv, scrub_secrets};
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wireguard::{LiveInterfaceStats, SimulatedWireGuardEngine, WireGuardEngine};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TestHarness {
|
||||
_dir: TempDir,
|
||||
store: Store,
|
||||
_state: AppState,
|
||||
wg_engine: Arc<SimulatedWireGuardEngine>,
|
||||
_net_engine: Arc<SimulatedNetworkEngine>,
|
||||
reconciler: Arc<ReconciliationEngine>,
|
||||
app: axum::Router,
|
||||
session_cookie: String,
|
||||
}
|
||||
|
||||
async fn setup_test_harness() -> TestHarness {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let db_path = dir.path().join("upstream_test.db");
|
||||
let store = Store::connect(&db_path.to_string_lossy())
|
||||
.await
|
||||
.expect("connect to db");
|
||||
store.migrate().await.expect("run migrations");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("AdminSecret123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap admin");
|
||||
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
let state = AppState::with_engines(store.clone(), wg_engine.clone(), net_engine.clone());
|
||||
let reconciler = Arc::new(ReconciliationEngine::new(
|
||||
state.clone(),
|
||||
wg_engine.clone(),
|
||||
net_engine.clone(),
|
||||
));
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Login to get session ID
|
||||
let login_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "AdminSecret123!"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(login_req).await.expect("login request");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let cookie_header = resp
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.expect("set-cookie")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let (wg0_priv, wg0_pub) = generate_keypair();
|
||||
let wg0 = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: wg0_priv,
|
||||
public_key: wg0_pub.clone(),
|
||||
listen_port: Some(51820),
|
||||
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store.create_interface(&wg0).await.unwrap();
|
||||
|
||||
let (client_priv, client_pub) = generate_keypair();
|
||||
let client_peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: wg0.id,
|
||||
name: "client-alice".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: client_pub,
|
||||
private_key: Some(client_priv),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.100.0.2/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(validate_cidr("10.100.0.2/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store.create_peer(&client_peer).await.unwrap();
|
||||
|
||||
// Baseline reconciliation to converge initial network/firewall/wg state
|
||||
reconciler.apply().await.unwrap();
|
||||
|
||||
TestHarness {
|
||||
_dir: dir,
|
||||
store,
|
||||
_state: state,
|
||||
wg_engine,
|
||||
_net_engine: net_engine,
|
||||
reconciler,
|
||||
app,
|
||||
session_cookie,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_proton_conf(priv_k_str: &str, provider_pub_k_str: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
# ProtonVPN WireGuard Configuration
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
PersistentKeepalive = 25
|
||||
"#,
|
||||
priv_k_str, provider_pub_k_str
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proton0_import_and_kernel_sync() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// 1. Preview API endpoint (read-only, no side effects)
|
||||
let preview_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/preview")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let preview_resp = harness.app.clone().oneshot(preview_req).await.unwrap();
|
||||
assert_eq!(preview_resp.status(), StatusCode::OK);
|
||||
let preview_body: Value = serde_json::from_slice(
|
||||
&to_bytes(preview_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(preview_body["name"], "proton0");
|
||||
assert_eq!(preview_body["role"], "upstream");
|
||||
assert_eq!(preview_body["address_v4"], "10.2.0.2/32");
|
||||
assert_eq!(preview_body["dns"], "10.2.0.1");
|
||||
assert_eq!(preview_body["provider_public_key"], provider_pub_k.as_str());
|
||||
assert_eq!(preview_body["provider_endpoint"], "37.19.199.155:51820");
|
||||
assert_eq!(preview_body["provider_allowed_ips"], "0.0.0.0/0, ::/0");
|
||||
assert_eq!(preview_body["persistent_keepalive"], 25);
|
||||
// Ensure secrets are never in response
|
||||
assert!(preview_body.get("private_key").is_none());
|
||||
assert!(preview_body.get("preshared_key").is_none());
|
||||
|
||||
// Verify DB still only has wg0 (preview didn't write to DB)
|
||||
assert_eq!(harness.store.list_interfaces().await.unwrap().len(), 1);
|
||||
|
||||
// 2. Import API endpoint (transactional persistence + kernel sync)
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(import_resp.status(), StatusCode::OK);
|
||||
let import_body: Value =
|
||||
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
|
||||
let iface_id = import_body["interface_id"].as_str().unwrap();
|
||||
let peer_id = import_body["peer_id"].as_str().unwrap();
|
||||
assert_eq!(import_body["name"], "proton0");
|
||||
assert_eq!(import_body["role"], "upstream");
|
||||
assert!(import_body.get("private_key").is_none());
|
||||
assert!(import_body.get("preshared_key").is_none());
|
||||
|
||||
// 3. Verify SQLite desired state
|
||||
let iface = harness
|
||||
.store
|
||||
.get_interface(Uuid::parse_str(iface_id).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("proton0 in db");
|
||||
assert_eq!(iface.name, "proton0");
|
||||
assert_eq!(iface.role, InterfaceRole::Upstream);
|
||||
assert_eq!(iface.public_key.as_str(), pub_k.as_str());
|
||||
|
||||
let peers = harness
|
||||
.store
|
||||
.list_peers_for_interface(iface.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].id.to_string(), peer_id);
|
||||
assert_eq!(peers[0].public_key.as_str(), provider_pub_k.as_str());
|
||||
assert_eq!(peers[0].allowed_ips, "0.0.0.0/0, ::/0");
|
||||
|
||||
// 4. Verify Kernel Simulation state
|
||||
let kernel_stats = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("proton0 in kernel");
|
||||
assert_eq!(kernel_stats.name, "proton0");
|
||||
assert!(kernel_stats.is_up);
|
||||
assert_eq!(kernel_stats.peers.len(), 1);
|
||||
assert_eq!(kernel_stats.peers[0].public_key, provider_pub_k.as_str());
|
||||
assert_eq!(
|
||||
kernel_stats.peers[0].endpoint,
|
||||
Some("37.19.199.155:51820".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
kernel_stats.peers[0].allowed_ips,
|
||||
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
|
||||
);
|
||||
assert_eq!(kernel_stats.peers[0].persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wg0_non_regression_during_upstream_operations() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// Import proton0
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 1. wg0 remains Overlay
|
||||
let wg0 = harness
|
||||
.store
|
||||
.get_interface_by_name("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("wg0 exists");
|
||||
assert_eq!(wg0.role, InterfaceRole::Overlay);
|
||||
assert_eq!(wg0.address_v4.to_string(), "10.100.0.1/24");
|
||||
|
||||
// 2. wg0 peers unchanged and RoadWarrior AllowedIPs remain strictly /32
|
||||
let wg0_peers = harness
|
||||
.store
|
||||
.list_peers_for_interface(wg0.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wg0_peers.len(), 1);
|
||||
assert_eq!(wg0_peers[0].name, "client-alice");
|
||||
assert_eq!(
|
||||
wg0_peers[0].server_wireguard_allowed_ips_for_role(InterfaceRole::Overlay),
|
||||
"10.100.0.2/32"
|
||||
);
|
||||
|
||||
// 3. Managed subnets for client NAT masquerade only includes Overlay interfaces
|
||||
let subnets = collect_managed_wg_subnets(&harness.store).await.unwrap();
|
||||
assert_eq!(subnets.len(), 1);
|
||||
assert_eq!(subnets[0].to_string(), "10.100.0.1/24");
|
||||
// proton0 address (10.2.0.2/32) is NOT in client NAT subnets!
|
||||
assert!(!subnets.iter().any(|s| s.to_string().contains("10.2.0.2")));
|
||||
|
||||
// 4. Reconciliation plan reports zero drift
|
||||
let plan = harness.reconciler.plan().await.unwrap();
|
||||
assert!(!plan.has_drift, "Plan must be clean and fully converged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_restart_lifecycle() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// Import proton0
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
let import_body: Value =
|
||||
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
let iface_id = import_body["interface_id"].as_str().unwrap();
|
||||
|
||||
// Restart proton0
|
||||
let restart_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}/restart"))
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let restart_resp = harness.app.clone().oneshot(restart_req).await.unwrap();
|
||||
assert_eq!(restart_resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify same interface ID in DB
|
||||
let iface_after = harness
|
||||
.store
|
||||
.get_interface(Uuid::parse_str(iface_id).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("iface exists");
|
||||
assert_eq!(iface_after.name, "proton0");
|
||||
assert_eq!(iface_after.role, InterfaceRole::Upstream);
|
||||
|
||||
// Verify provider peer restored in kernel
|
||||
let kernel_stats = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("proton0 live");
|
||||
assert_eq!(kernel_stats.peers.len(), 1);
|
||||
assert_eq!(kernel_stats.peers[0].public_key, provider_pub_k.as_str());
|
||||
assert_eq!(
|
||||
kernel_stats.peers[0].allowed_ips,
|
||||
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_delete_lifecycle() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let conf = sample_proton_conf(priv_k.as_str(), provider_pub_k.as_str());
|
||||
|
||||
// Import proton0
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
let import_body: Value =
|
||||
serde_json::from_slice(&to_bytes(import_resp.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
let iface_id = import_body["interface_id"].as_str().unwrap();
|
||||
|
||||
// Verify present in kernel before delete
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// Delete proton0
|
||||
let del_req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let del_resp = harness.app.clone().oneshot(del_req).await.unwrap();
|
||||
assert_eq!(del_resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify absent from kernel
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Verify absent from DB
|
||||
assert!(
|
||||
harness
|
||||
.store
|
||||
.get_interface(Uuid::parse_str(iface_id).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Verify wg0 remains untouched
|
||||
assert!(
|
||||
harness
|
||||
.store
|
||||
.get_interface_by_name("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_reconciliation_orphan_detection() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
// Inject an orphan upstream interface into simulated kernel
|
||||
harness
|
||||
.wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "orphan_vpn0".to_string(),
|
||||
public_key: "orphanpubkey12345".to_string(),
|
||||
listen_port: 51830,
|
||||
fwmark: 0,
|
||||
peers: vec![],
|
||||
addresses: vec!["10.99.0.1/24".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Detect orphan in plan
|
||||
let plan = harness.reconciler.plan().await.unwrap();
|
||||
assert!(plan.has_drift);
|
||||
let orphan_action = plan
|
||||
.actions
|
||||
.iter()
|
||||
.find(|a| a.resource_id == "orphan_vpn0")
|
||||
.expect("orphan action in plan");
|
||||
assert_eq!(orphan_action.action_type, "delete_orphan_interface");
|
||||
|
||||
// Apply cleanup
|
||||
let report = harness.reconciler.apply().await.unwrap();
|
||||
assert!(
|
||||
report
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.contains("Removed orphan kernel interface 'orphan_vpn0'"))
|
||||
);
|
||||
|
||||
// Verify orphan was deleted from kernel
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("orphan_vpn0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Verify wg0 remains active
|
||||
assert!(
|
||||
harness
|
||||
.wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_secret_safety() {
|
||||
let harness = setup_test_harness().await;
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, provider_pub_k) = generate_keypair();
|
||||
let raw_priv = priv_k.as_str().to_string();
|
||||
let conf = sample_proton_conf(&raw_priv, provider_pub_k.as_str());
|
||||
|
||||
// 1. Preview response secret check
|
||||
let preview_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/preview")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let preview_resp = harness.app.clone().oneshot(preview_req).await.unwrap();
|
||||
let preview_text = String::from_utf8(
|
||||
to_bytes(preview_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!preview_text.contains(&raw_priv),
|
||||
"PrivateKey leaked in preview response"
|
||||
);
|
||||
|
||||
// 2. Import response secret check
|
||||
let import_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let import_resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
let import_text = String::from_utf8(
|
||||
to_bytes(import_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!import_text.contains(&raw_priv),
|
||||
"PrivateKey leaked in import response"
|
||||
);
|
||||
|
||||
// 3. Read-only CLI output secret scrubber check
|
||||
let scrubbed = scrub_secrets(&format!(
|
||||
"private_key: {}\nPrivateKey = {}",
|
||||
raw_priv, raw_priv
|
||||
));
|
||||
assert!(
|
||||
!scrubbed.contains(&raw_priv),
|
||||
"PrivateKey leaked past scrubber"
|
||||
);
|
||||
|
||||
// 4. Safe argv builder allows read-only Upstream queries
|
||||
let list_req = ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("upstream".to_string()),
|
||||
sub_subcommand: Some("list".to_string()),
|
||||
target: None,
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
let argv = build_safe_argv(&list_req).unwrap();
|
||||
assert_eq!(argv, vec!["interface", "upstream", "list"]);
|
||||
|
||||
// 5. Prohibited mutating commands rejected by CLI allowlist
|
||||
let import_cli_req = ExecuteCliRequest {
|
||||
command: "interface".to_string(),
|
||||
subcommand: Some("upstream".to_string()),
|
||||
sub_subcommand: Some("import".to_string()),
|
||||
target: Some("proton0".to_string()),
|
||||
parameters: HashMap::new(),
|
||||
};
|
||||
assert!(build_safe_argv(&import_cli_req).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_without_listen_port_does_not_conflict_with_wg0() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
// 1. Verify wg0 already owns local UDP 51820
|
||||
let wg0_initial = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wg0_initial.listen_port, 51820);
|
||||
|
||||
// 2. Import proton0 from a configuration with no ListenPort
|
||||
let (proton_priv, _) = generate_keypair();
|
||||
let (_, provider_pub) = generate_keypair();
|
||||
let conf = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
PersistentKeepalive = 25
|
||||
"#,
|
||||
proton_priv.as_str(),
|
||||
provider_pub.as_str()
|
||||
);
|
||||
|
||||
let import_req = Request::builder()
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.method("POST")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "proton0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let import_res: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(import_res["name"], "proton0");
|
||||
assert_eq!(import_res["role"], "upstream");
|
||||
assert_eq!(import_res["listen_port"], Value::Null);
|
||||
assert_eq!(import_res["provider_endpoint"], "37.19.199.155:51820");
|
||||
assert_eq!(import_res["provider_allowed_ips"], "0.0.0.0/0, ::/0");
|
||||
|
||||
// 3. Verify wg0 remains on UDP 51820 and unchanged
|
||||
let wg0_db = harness
|
||||
.store
|
||||
.get_interface_by_name("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wg0_db.listen_port, Some(51820));
|
||||
assert_eq!(wg0_db.role, InterfaceRole::Overlay);
|
||||
|
||||
// 4. Verify proton0 desired state in DB has listen_port = None
|
||||
let proton_db = harness
|
||||
.store
|
||||
.get_interface_by_name("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(proton_db.listen_port, None);
|
||||
assert_eq!(proton_db.role, InterfaceRole::Upstream);
|
||||
|
||||
// 5. Verify simulated kernel state has both wg0 (51820) and proton0 (dynamic/0)
|
||||
let live_wg0 = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(live_wg0.listen_port, 51820);
|
||||
|
||||
let live_proton = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("proton0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(live_proton.listen_port, 0);
|
||||
assert_eq!(live_proton.peers.len(), 1);
|
||||
assert_eq!(
|
||||
live_proton.peers[0].allowed_ips,
|
||||
vec!["0.0.0.0/0".to_string(), "::/0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_explicit_upstream_listen_port_is_preserved() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
let (proton_priv, _) = generate_keypair();
|
||||
let (_, provider_pub) = generate_keypair();
|
||||
let conf = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
ListenPort = 45000
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
"#,
|
||||
proton_priv.as_str(),
|
||||
provider_pub.as_str()
|
||||
);
|
||||
|
||||
let import_req = Request::builder()
|
||||
.uri("/api/v1/interfaces/upstreams/import")
|
||||
.method("POST")
|
||||
.header(header::COOKIE, &harness.session_cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "custom_vpn0",
|
||||
"config": conf
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = harness.app.clone().oneshot(import_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let import_res: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(import_res["listen_port"], 45000);
|
||||
|
||||
let iface_db = harness
|
||||
.store
|
||||
.get_interface_by_name("custom_vpn0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(iface_db.listen_port, Some(45000));
|
||||
|
||||
let live_custom = harness
|
||||
.wg_engine
|
||||
.get_interface_stats("custom_vpn0")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(live_custom.listen_port, 45000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upstream_missing_listen_port_no_false_drift() {
|
||||
let harness = setup_test_harness().await;
|
||||
|
||||
// 1. Create upstream interface proton0 in DB with listen_port = None
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let (_, peer_pub) = generate_keypair();
|
||||
let iface_id = Uuid::new_v4();
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "proton0".to_string(),
|
||||
role: InterfaceRole::Upstream,
|
||||
private_key: priv_k,
|
||||
public_key: pub_k.clone(),
|
||||
listen_port: None,
|
||||
address_v4: validate_cidr("10.2.0.2/32").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: None,
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
harness.store.create_interface(&iface).await.unwrap();
|
||||
|
||||
let peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface_id,
|
||||
name: "proton0-provider".to_string(),
|
||||
peer_type: PeerType::Server,
|
||||
state: PeerState::Active,
|
||||
public_key: peer_pub.clone(),
|
||||
private_key: None,
|
||||
preshared_key: None,
|
||||
endpoint: Some("37.19.199.155:51820".to_string()),
|
||||
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||
server_allowed_ips: Some("0.0.0.0/0, ::/0".to_string()),
|
||||
address_v4: None,
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: Some(1420),
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::Custom,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
};
|
||||
harness.store.create_peer(&peer).await.unwrap();
|
||||
|
||||
// 2. Inject live kernel stats where the kernel has allocated an ephemeral dynamic port 54321
|
||||
harness
|
||||
.wg_engine
|
||||
.inject_interface_stats(LiveInterfaceStats {
|
||||
name: "proton0".to_string(),
|
||||
public_key: pub_k.as_str().to_string(),
|
||||
listen_port: 54321, // dynamic kernel-allocated port
|
||||
fwmark: 0,
|
||||
peers: vec![nx9_wireguard::LivePeerStats {
|
||||
public_key: peer_pub.as_str().to_string(),
|
||||
endpoint: Some("37.19.199.155:51820".to_string()),
|
||||
rx_bytes: 100,
|
||||
tx_bytes: 200,
|
||||
last_handshake_at: None,
|
||||
allowed_ips: vec!["0.0.0.0/0".to_string(), "::/0".to_string()],
|
||||
persistent_keepalive: Some(25),
|
||||
}],
|
||||
addresses: vec!["10.2.0.2/32".to_string()],
|
||||
mtu: Some(1420),
|
||||
is_up: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
// 3. Run reconciliation plan — must NOT flag drift for the dynamic listen port
|
||||
let plan = harness.reconciler.plan().await.unwrap();
|
||||
assert!(
|
||||
!plan.has_drift,
|
||||
"Expected zero drift for dynamic kernel listen port when desired listen_port is None, but got: {:?}",
|
||||
plan.actions
|
||||
);
|
||||
assert_eq!(plan.interface_changes, 0);
|
||||
assert_eq!(plan.peer_changes, 0);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ use nx9_wg_core::types::firewall::{
|
||||
};
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey, WireGuardPublicKey,
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey,
|
||||
WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
@@ -61,13 +62,14 @@ async fn test_automatic_ip_allocation() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg50".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51850,
|
||||
listen_port: Some(51850),
|
||||
address_v4: "10.50.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -152,13 +154,14 @@ async fn test_peer_expiration_lifecycle() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg60".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51860,
|
||||
listen_port: Some(51860),
|
||||
address_v4: "10.60.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -288,13 +291,14 @@ async fn test_peer_firewall_and_port_ranges() {
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg70".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51870,
|
||||
listen_port: Some(51870),
|
||||
address_v4: "10.70.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
Reference in new issue
Block a user