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