cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,242 @@
|
||||
//! Integration tests for Phase 2: Authentication, Admin Bootstrap, Rate Limiting, and Security.
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use nx9_wg_api::auth::{AuthService, BootstrapOptions, ResolvedSource, bootstrap_admin};
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_db::Store;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_bootstrap_all_sources_and_rejection() {
|
||||
let config = AppConfig::default();
|
||||
|
||||
// 1. Bootstrap with explicit CLI password
|
||||
let store = Store::connect_in_memory().await.expect("connect");
|
||||
store.migrate().await.expect("migrate");
|
||||
|
||||
let opts = BootstrapOptions {
|
||||
admin_username: Some("custom_admin".to_string()),
|
||||
cli_password: Some("SecurePassword123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap cli");
|
||||
assert_eq!(res.source, ResolvedSource::CliArgument);
|
||||
assert_eq!(res.admin.username, "custom_admin");
|
||||
|
||||
// Re-bootstrap must fail
|
||||
let re_bootstrap = bootstrap_admin(&store, &config, &opts).await;
|
||||
assert!(re_bootstrap.is_err(), "re-bootstrap must be rejected");
|
||||
|
||||
// 2. Bootstrap from password file
|
||||
let store2 = Store::connect_in_memory().await.expect("connect");
|
||||
store2.migrate().await.expect("migrate");
|
||||
|
||||
let tmp_file = NamedTempFile::new().expect("temp file");
|
||||
std::fs::write(tmp_file.path(), "FileSecretPass999!\n").expect("write secret");
|
||||
|
||||
let opts2 = BootstrapOptions {
|
||||
password_file: Some(tmp_file.path().to_str().unwrap().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res2 = bootstrap_admin(&store2, &config, &opts2)
|
||||
.await
|
||||
.expect("bootstrap file");
|
||||
assert_eq!(res2.source, ResolvedSource::PasswordFile);
|
||||
assert_eq!(res2.admin.username, "admin");
|
||||
|
||||
// 3. Bootstrap from generated password
|
||||
let store3 = Store::connect_in_memory().await.expect("connect");
|
||||
store3.migrate().await.expect("migrate");
|
||||
|
||||
let gen_file = NamedTempFile::new().expect("gen file");
|
||||
let opts3 = BootstrapOptions {
|
||||
generate_password: true,
|
||||
write_password_file: Some(gen_file.path().to_str().unwrap().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res3 = bootstrap_admin(&store3, &config, &opts3)
|
||||
.await
|
||||
.expect("bootstrap gen");
|
||||
assert_eq!(res3.source, ResolvedSource::Generated);
|
||||
assert!(res3.generated_plaintext.is_some());
|
||||
let gen_pw = res3.generated_plaintext.unwrap();
|
||||
let written = std::fs::read_to_string(gen_file.path()).expect("read gen");
|
||||
assert_eq!(written, gen_pw);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_service_login_and_rate_limiting() {
|
||||
let store = Store::connect_in_memory().await.expect("connect");
|
||||
store.migrate().await.expect("migrate");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("AdminSecret123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let auth = AuthService::new(store);
|
||||
|
||||
// Successful login
|
||||
let session = auth
|
||||
.login(
|
||||
"admin",
|
||||
"AdminSecret123!",
|
||||
Some("192.168.1.50"),
|
||||
Some("TestBrowser/1.0"),
|
||||
)
|
||||
.await
|
||||
.expect("successful login");
|
||||
assert_eq!(session.admin_id, 1);
|
||||
assert_eq!(session.ip_address.as_deref(), Some("192.168.1.50"));
|
||||
|
||||
// Authenticate with valid session
|
||||
let authenticated = auth
|
||||
.authenticate_session(&session.id)
|
||||
.await
|
||||
.expect("authenticate session");
|
||||
assert_eq!(authenticated.id, session.id);
|
||||
|
||||
// Wrong password login fails
|
||||
let fail = auth
|
||||
.login("admin", "WrongPass123!", Some("192.168.1.50"), None)
|
||||
.await;
|
||||
assert!(fail.is_err(), "wrong password must fail");
|
||||
|
||||
// Test rate-limit lockout after 5 failed attempts from same IP
|
||||
let attacker_ip = "10.0.0.99";
|
||||
for _ in 0..5 {
|
||||
let _ = auth
|
||||
.login("admin", "WrongPass123!", Some(attacker_ip), None)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 6th attempt must be rejected with rate limit lockout even with correct password
|
||||
let lockout = auth
|
||||
.login("admin", "AdminSecret123!", Some(attacker_ip), None)
|
||||
.await;
|
||||
assert!(lockout.is_err());
|
||||
let err_msg = lockout.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("rate limited") || err_msg.contains("Too many failed"),
|
||||
"error should indicate rate limit lockout: {err_msg}"
|
||||
);
|
||||
|
||||
// Login from another IP should still succeed
|
||||
let other_ip_login = auth
|
||||
.login("admin", "AdminSecret123!", Some("192.168.1.60"), None)
|
||||
.await;
|
||||
assert!(
|
||||
other_ip_login.is_ok(),
|
||||
"different IP must not be locked out"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_service_password_change_invalidates_sessions() {
|
||||
let store = Store::connect_in_memory().await.expect("connect");
|
||||
store.migrate().await.expect("migrate");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("OriginalPassword123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let auth = AuthService::new(store.clone());
|
||||
|
||||
// Create two active sessions
|
||||
let s1 = auth
|
||||
.login("admin", "OriginalPassword123!", Some("1.1.1.1"), None)
|
||||
.await
|
||||
.expect("login 1");
|
||||
let s2 = auth
|
||||
.login("admin", "OriginalPassword123!", Some("2.2.2.2"), None)
|
||||
.await
|
||||
.expect("login 2");
|
||||
|
||||
assert!(auth.authenticate_session(&s1.id).await.is_ok());
|
||||
assert!(auth.authenticate_session(&s2.id).await.is_ok());
|
||||
|
||||
// Change password
|
||||
auth.change_password("NewRotatedPassword456!", Some("1.1.1.1"))
|
||||
.await
|
||||
.expect("change password");
|
||||
|
||||
// Both previous sessions must now be rejected
|
||||
assert!(
|
||||
auth.authenticate_session(&s1.id).await.is_err(),
|
||||
"s1 must be invalidated"
|
||||
);
|
||||
assert!(
|
||||
auth.authenticate_session(&s2.id).await.is_err(),
|
||||
"s2 must be invalidated"
|
||||
);
|
||||
|
||||
// Old password must fail; new password must succeed
|
||||
assert!(
|
||||
auth.login("admin", "OriginalPassword123!", None, None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let new_login = auth
|
||||
.login("admin", "NewRotatedPassword456!", None, None)
|
||||
.await
|
||||
.expect("new login");
|
||||
assert!(auth.authenticate_session(&new_login.id).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_service_api_tokens() {
|
||||
let store = Store::connect_in_memory().await.expect("connect");
|
||||
store.migrate().await.expect("migrate");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("AdminSecret123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let auth = AuthService::new(store);
|
||||
|
||||
// Create API token
|
||||
let (token_meta, raw_token) = auth
|
||||
.create_api_token(
|
||||
"Terraform Runner",
|
||||
Some(Utc::now().naive_utc() + Duration::days(7)),
|
||||
Some("10.0.0.1"),
|
||||
)
|
||||
.await
|
||||
.expect("create token");
|
||||
assert!(raw_token.starts_with("nx9_"));
|
||||
assert_eq!(token_meta.name, "Terraform Runner");
|
||||
|
||||
// Authenticate with raw token
|
||||
let authenticated = auth
|
||||
.authenticate_token(&raw_token)
|
||||
.await
|
||||
.expect("authenticate token");
|
||||
assert_eq!(authenticated.id, token_meta.id);
|
||||
|
||||
// Revoke token
|
||||
auth.revoke_api_token(&token_meta.id, Some("10.0.0.1"))
|
||||
.await
|
||||
.expect("revoke");
|
||||
|
||||
// Authenticating revoked token must fail
|
||||
assert!(
|
||||
auth.authenticate_token(&raw_token).await.is_err(),
|
||||
"revoked token must fail authentication"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Integration test suite for Backup and Restore engine.
|
||||
|
||||
use nx9_wg_api::backup::BackupService;
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backup_create_verify_and_restore() {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let active_db_path = dir.path().join("active.db");
|
||||
let backup_dir = dir.path().join("backups");
|
||||
let safety_dir = dir.path().join("safety");
|
||||
|
||||
let store = Store::connect(&active_db_path.to_string_lossy())
|
||||
.await
|
||||
.expect("connect to db");
|
||||
store.migrate().await.expect("run migrations");
|
||||
|
||||
// Insert test record
|
||||
let net = Network {
|
||||
id: Uuid::new_v4(),
|
||||
name: "test_lan".to_string(),
|
||||
cidr: validate_cidr("10.50.0.0/24").unwrap(),
|
||||
enabled: true,
|
||||
description: Some("LAN subnet".to_string()),
|
||||
created_at: chrono::Utc::now().naive_utc(),
|
||||
updated_at: chrono::Utc::now().naive_utc(),
|
||||
};
|
||||
store.create_network(&net).await.expect("create network");
|
||||
|
||||
// Create Backup
|
||||
let (meta, backup_file) = BackupService::create_backup(
|
||||
&store,
|
||||
&backup_dir,
|
||||
Some("Test backup snapshot"),
|
||||
"test_admin",
|
||||
Some("127.0.0.1"),
|
||||
)
|
||||
.await
|
||||
.expect("create backup");
|
||||
|
||||
assert!(backup_file.exists());
|
||||
assert!(meta.size_bytes > 0);
|
||||
assert!(!meta.checksum.is_empty());
|
||||
|
||||
// Verify Backup
|
||||
let is_valid =
|
||||
BackupService::verify_backup(&backup_file, Some(&meta.checksum)).expect("verify backup");
|
||||
assert!(is_valid, "Backup file should be valid SQLite archive");
|
||||
|
||||
// List backups from store
|
||||
let backups = store.list_backups().await.expect("list backups");
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(backups[0].id, meta.id);
|
||||
|
||||
// Modify active DB by adding another network
|
||||
let net2 = Network {
|
||||
id: Uuid::new_v4(),
|
||||
name: "temporary_lan".to_string(),
|
||||
cidr: validate_cidr("10.99.0.0/24").unwrap(),
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: chrono::Utc::now().naive_utc(),
|
||||
updated_at: chrono::Utc::now().naive_utc(),
|
||||
};
|
||||
store.create_network(&net2).await.expect("create net2");
|
||||
assert_eq!(store.list_networks().await.unwrap().len(), 2);
|
||||
|
||||
// Restore Backup
|
||||
BackupService::restore_backup(
|
||||
&store,
|
||||
&backup_file,
|
||||
&active_db_path,
|
||||
&safety_dir,
|
||||
"test_admin",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("restore backup");
|
||||
|
||||
// Reopen store to verify restored content
|
||||
let restored_store = Store::connect(&active_db_path.to_string_lossy())
|
||||
.await
|
||||
.expect("reconnect store");
|
||||
let restored_networks = restored_store.list_networks().await.expect("list restored");
|
||||
assert_eq!(restored_networks.len(), 1);
|
||||
assert_eq!(restored_networks[0].name, "test_lan");
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Integration tests for Client Profiles REST API endpoints and resolver.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, ResolvedClientProfile};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wg_db::Store;
|
||||
use std::str::FromStr;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
|
||||
let store = Store::connect_in_memory().await.unwrap();
|
||||
store.migrate().await.unwrap();
|
||||
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let hash = nx9_wg_core::crypto::hash_password("adminpassword123").unwrap();
|
||||
store.create_admin("admin", &hash).await.unwrap();
|
||||
|
||||
// Create session token
|
||||
let session = nx9_wg_core::types::auth::Session {
|
||||
id: "test-session-id-12345".to_string(),
|
||||
admin_id: 1,
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(24),
|
||||
last_seen_at: Some(now),
|
||||
ip_address: Some("127.0.0.1".to_string()),
|
||||
user_agent: Some("test-agent".to_string()),
|
||||
};
|
||||
store.create_session(&session).await.unwrap();
|
||||
|
||||
let (srv_priv, srv_pub) = generate_keypair();
|
||||
let (peer_priv, peer_pub) = generate_keypair();
|
||||
|
||||
let interface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store.create_interface(&interface).await.unwrap();
|
||||
|
||||
let peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: interface.id,
|
||||
name: "test-mobile-peer".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: peer_pub,
|
||||
private_key: Some(peer_priv),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.0.0.2/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: None,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store.create_peer(&peer).await.unwrap();
|
||||
|
||||
let state = AppState::new(store);
|
||||
let app = nx9_wg_api::routes::build_api_router(state.clone());
|
||||
|
||||
(app, state, session.id, interface, peer)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_profiles_endpoints() {
|
||||
let (app, _state, session_id, _iface, peer) = setup_test_app().await;
|
||||
|
||||
// 1. List client profiles
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/client-profiles")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles: Vec<ClientProfile> = serde_json::from_slice(&body).unwrap();
|
||||
assert!(profiles.len() >= 10);
|
||||
|
||||
// 2. List distinct providers
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/client-profiles/providers")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let providers: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||
assert!(providers.contains(&"tmobile".to_string()));
|
||||
assert!(providers.contains(&"starlink".to_string()));
|
||||
|
||||
// 3. List device categories
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/client-profiles/devices")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
// 4. Resolve client profile via POST
|
||||
let resolve_body = serde_json::json!({
|
||||
"connection": "mobile",
|
||||
"device": "android",
|
||||
"nat": "cgnat"
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/client-profiles/resolve")
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&resolve_body).unwrap()))
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let resolved: ResolvedClientProfile = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(resolved.mtu, 1280);
|
||||
assert_eq!(resolved.connection_type, ConnectionType::Mobile);
|
||||
|
||||
// 5. Download peer .conf with mobile profile parameters
|
||||
let req = Request::builder()
|
||||
.uri(format!(
|
||||
"/api/v1/peers/{}/config?connection=mobile&device=android",
|
||||
peer.id
|
||||
))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let conf_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(conf_str.contains("MTU = 1280"));
|
||||
assert!(conf_str.contains("PersistentKeepalive = 25"));
|
||||
|
||||
// 6. Get QR code with CGNAT profile parameters
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{}/qr?nat=cgnat", peer.id))
|
||||
.header("Cookie", format!("nx9_session={session_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Integration test suite for Reconciliation Engine.
|
||||
|
||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::Interface;
|
||||
use nx9_wg_core::validation::validate_cidr;
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::SimulatedNetworkEngine;
|
||||
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reconciliation_engine_drift_detection_and_apply() {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let db_path = dir.path().join("reconcile.db");
|
||||
let store = Store::connect(&db_path.to_string_lossy())
|
||||
.await
|
||||
.expect("connect to db");
|
||||
store.migrate().await.expect("run migrations");
|
||||
|
||||
let state = AppState::new(store.clone());
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
let reconciler = ReconciliationEngine::new(state, wg_engine.clone(), net_engine.clone());
|
||||
|
||||
// 1. Create desired interface in SQLite
|
||||
let (priv_key, pub_key) = generate_keypair();
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
private_key: priv_key,
|
||||
public_key: pub_key,
|
||||
listen_port: 51820,
|
||||
address_v4: validate_cidr("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: None,
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: chrono::Utc::now().naive_utc(),
|
||||
updated_at: chrono::Utc::now().naive_utc(),
|
||||
};
|
||||
store
|
||||
.create_interface(&iface)
|
||||
.await
|
||||
.expect("create interface");
|
||||
|
||||
// 2. Compute plan: should detect missing wg0 in kernel
|
||||
let plan = reconciler.plan().await.expect("compute plan");
|
||||
assert!(plan.has_drift);
|
||||
assert_eq!(plan.interface_changes, 1);
|
||||
assert!(!plan.actions.is_empty());
|
||||
|
||||
// 3. Apply reconciliation
|
||||
let report = reconciler.apply().await.expect("apply plan");
|
||||
assert!(report.success);
|
||||
assert!(report.executed_actions > 0);
|
||||
|
||||
// 4. Verify live WireGuard interface state
|
||||
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap();
|
||||
assert!(live_stats.is_some());
|
||||
let stats = live_stats.unwrap();
|
||||
assert_eq!(stats.name, "wg0");
|
||||
assert_eq!(stats.listen_port, 51820);
|
||||
|
||||
// 5. Verify audit event was logged
|
||||
let audits = store
|
||||
.list_audit_events(&nx9_wg_db::AuditFilter::default(), 10, 0)
|
||||
.await
|
||||
.expect("list audits");
|
||||
assert!(!audits.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_core::config::AppConfig;
|
||||
use nx9_wg_db::Store;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn setup_test_app() -> (axum::Router, String) {
|
||||
let store = Store::connect_in_memory().await.expect("connect in-memory");
|
||||
store.migrate().await.expect("migrate");
|
||||
|
||||
let config = AppConfig::default();
|
||||
let opts = BootstrapOptions {
|
||||
cli_password: Some("AdminSecret123!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bootstrap_admin(&store, &config, &opts)
|
||||
.await
|
||||
.expect("bootstrap");
|
||||
|
||||
let state = AppState::new(store);
|
||||
let app = build_api_router(state.clone());
|
||||
|
||||
// Login to get session ID
|
||||
let login_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "AdminSecret123!"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(login_req).await.expect("login request");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let cookie_header = resp
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.expect("set-cookie")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
|
||||
|
||||
(app, session_cookie)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_public_health_and_version_endpoints() {
|
||||
let (app, _) = setup_test_app().await;
|
||||
|
||||
// Health
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/system/health")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(val["status"], "healthy");
|
||||
assert_eq!(val["database"], "connected");
|
||||
|
||||
// Version
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/system/version")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(val["name"], "nx9-wg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_protected_route_unauthenticated_rejection() {
|
||||
let (app, _) = setup_test_app().await;
|
||||
|
||||
// Request protected route without auth
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/system")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interfaces_and_peers_rest_lifecycle() {
|
||||
let (app, cookie) = setup_test_app().await;
|
||||
|
||||
// 1. Create interface
|
||||
let create_iface_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/interfaces")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"address_v4": "10.0.0.1/24",
|
||||
"dns": "1.1.1.1"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let iface_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
let iface_id = iface_val["id"].as_str().unwrap();
|
||||
assert_eq!(iface_val["name"], "wg0");
|
||||
|
||||
// 2. List interfaces
|
||||
let list_req = Request::builder()
|
||||
.uri("/api/v1/interfaces")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(list_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 3. Create peer under interface
|
||||
let create_peer_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "laptop-alice",
|
||||
"peer_type": "road_warrior",
|
||||
"profile": "full_tunnel",
|
||||
"allowed_ips": "10.0.0.2/32"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.clone().oneshot(create_peer_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let peer_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
let peer_id = peer_val["id"].as_str().unwrap();
|
||||
assert_eq!(peer_val["name"], "laptop-alice");
|
||||
|
||||
// 4. Disable peer
|
||||
let disable_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/peers/{peer_id}/disable"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(disable_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 5. Get peer and verify state
|
||||
let get_peer_req = Request::builder()
|
||||
.uri(format!("/api/v1/peers/{peer_id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(get_peer_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let peer_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(peer_val["state"], "disabled");
|
||||
|
||||
// 6. Delete interface (cascades peer)
|
||||
let del_iface_req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(del_iface_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_networks_and_firewall_rest_lifecycle() {
|
||||
let (app, cookie) = setup_test_app().await;
|
||||
|
||||
// Create network
|
||||
let net_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/networks")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "Management Network",
|
||||
"cidr": "10.10.0.0/16",
|
||||
"description": "Internal management"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(net_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Create firewall rule
|
||||
let fw_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/firewall/rules")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"name": "Allow HTTPS",
|
||||
"direction": "in",
|
||||
"action": "accept",
|
||||
"protocol": "tcp",
|
||||
"destination_port": 443,
|
||||
"priority": 10
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(fw_req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let rule_val: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(rule_val["name"], "Allow HTTPS");
|
||||
assert_eq!(rule_val["priority"], 10);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Integration tests for embedded Web UI SPA and static asset endpoints.
|
||||
|
||||
use axum::body::to_bytes;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use nx9_wg_api::routes::build_api_router;
|
||||
use nx9_wg_api::state::AppState;
|
||||
use nx9_wg_db::Store;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
||||
let store = Store::connect_in_memory().await.expect("connect store");
|
||||
store.migrate().await.expect("migrate store");
|
||||
let state = AppState::new(store);
|
||||
let app = build_api_router(state);
|
||||
|
||||
// 1. Test GET / (Root SPA Index)
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("execute request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
|
||||
let body_bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body_bytes);
|
||||
assert!(html.contains("nx9-wg — Native WireGuard Appliance"));
|
||||
assert!(html.contains("NX9"));
|
||||
assert!(html.contains("id=\"app-layout\""));
|
||||
assert!(html.contains("id=\"sidebar\""));
|
||||
assert!(html.contains("Dashboard"));
|
||||
assert!(html.contains("Peers"));
|
||||
assert!(html.contains("Diagnostics"));
|
||||
assert!(html.contains("Administrator"));
|
||||
|
||||
// 2. Test GET /ui (Alias)
|
||||
let res_ui = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/ui")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("execute request");
|
||||
|
||||
assert_eq!(res_ui.status(), StatusCode::OK);
|
||||
|
||||
// 3. Test GET /assets/style.css (Compiled CSS)
|
||||
let res_css = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/assets/style.css")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("execute request");
|
||||
|
||||
assert_eq!(res_css.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
res_css
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"text/css; charset=utf-8"
|
||||
);
|
||||
|
||||
let css_bytes = to_bytes(res_css.into_body(), 1024 * 1024).await.unwrap();
|
||||
let css = String::from_utf8_lossy(&css_bytes);
|
||||
assert!(css.contains("--bg-base: #0d1117;"));
|
||||
assert!(css.contains("[data-theme=\"dark\"]"));
|
||||
assert!(css.contains("[data-theme=\"light\"]"));
|
||||
assert!(css.contains(".status-pass"));
|
||||
assert!(css.contains(".status-fail"));
|
||||
assert!(css.contains("@media (max-width: 768px)"));
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use nx9_wg_api::{AppState, DiagnosticsService, IpAllocator, ReconciliationEngine};
|
||||
use nx9_wg_core::types::diagnostics::DiagnosticSubsystem;
|
||||
use nx9_wg_core::types::firewall::{
|
||||
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||
};
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey, WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_db::Store;
|
||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn setup_test_context() -> (
|
||||
AppState,
|
||||
Arc<SimulatedWireGuardEngine>,
|
||||
Arc<SimulatedNetworkEngine>,
|
||||
Arc<ReconciliationEngine>,
|
||||
) {
|
||||
let store = Store::connect_in_memory().await.expect("connect DB");
|
||||
store.migrate().await.expect("migrate DB");
|
||||
|
||||
let state = AppState::new(store);
|
||||
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
||||
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
||||
let reconciler = Arc::new(ReconciliationEngine::new(
|
||||
state.clone(),
|
||||
wg_engine.clone(),
|
||||
net_engine.clone(),
|
||||
));
|
||||
|
||||
(state, wg_engine, net_engine, reconciler)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_automatic_ip_allocation() {
|
||||
let (state, _, _, _) = setup_test_context().await;
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let net_id = Uuid::new_v4();
|
||||
let network = Network {
|
||||
id: net_id,
|
||||
name: "Test-V4-Subnet".to_string(),
|
||||
cidr: "10.50.0.0/24".parse().unwrap(),
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.create_network(&network)
|
||||
.await
|
||||
.expect("create net");
|
||||
|
||||
let iface_id = Uuid::new_v4();
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg50".to_string(),
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51850,
|
||||
address_v4: "10.50.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: None,
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.create_interface(&iface)
|
||||
.await
|
||||
.expect("create iface");
|
||||
|
||||
// First allocation: 10.50.0.1 is interface -> next available is 10.50.0.2/32
|
||||
let ip1 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
|
||||
.await
|
||||
.expect("allocate ip1");
|
||||
assert_eq!(ip1.to_string(), "10.50.0.2/32");
|
||||
|
||||
// Create a peer with this allocated IP
|
||||
let peer1 = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface_id,
|
||||
name: "peer-1".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"peer1pubkey12345678901234567890123456789012=".to_string(),
|
||||
),
|
||||
private_key: None,
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: ip1.to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(ip1),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: None,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.store.create_peer(&peer1).await.expect("create peer1");
|
||||
|
||||
// Second allocation: next should be 10.50.0.3/32
|
||||
let ip2 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
|
||||
.await
|
||||
.expect("allocate ip2");
|
||||
assert_eq!(ip2.to_string(), "10.50.0.3/32");
|
||||
|
||||
// List available IPs: first should be 10.50.0.3
|
||||
let available = IpAllocator::list_available_ips(&state.store, &network, Some(&iface), 5)
|
||||
.await
|
||||
.expect("list available");
|
||||
assert_eq!(available.len(), 5);
|
||||
assert_eq!(available[0], "10.50.0.3".parse::<IpAddr>().unwrap());
|
||||
assert_eq!(available[1], "10.50.0.4".parse::<IpAddr>().unwrap());
|
||||
|
||||
// List allocations: should show peer1
|
||||
let allocs = IpAllocator::list_allocations(&state.store, &network)
|
||||
.await
|
||||
.expect("list allocs");
|
||||
assert_eq!(allocs.len(), 1);
|
||||
assert_eq!(allocs[0].ip_address, "10.50.0.2/32");
|
||||
assert_eq!(allocs[0].peer_name.as_deref(), Some("peer-1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_peer_expiration_lifecycle() {
|
||||
let (state, wg_engine, _net_engine, reconciler) = setup_test_context().await;
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface_id = Uuid::new_v4();
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg60".to_string(),
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51860,
|
||||
address_v4: "10.60.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: None,
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.create_interface(&iface)
|
||||
.await
|
||||
.expect("create iface");
|
||||
|
||||
// Peer with expiration in the past
|
||||
let expired_peer_id = Uuid::new_v4();
|
||||
let expired_peer = Peer {
|
||||
id: expired_peer_id,
|
||||
interface_id: iface_id,
|
||||
name: "expired-peer".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active, // marked active initially
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"expiredpubkey123456789012345678901234567890=".to_string(),
|
||||
),
|
||||
private_key: None,
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.60.0.5/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some("10.60.0.5/32".parse().unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: None,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: Some(now - Duration::hours(1)), // expired 1 hour ago
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.create_peer(&expired_peer)
|
||||
.await
|
||||
.expect("create peer");
|
||||
|
||||
// Active peer without expiration
|
||||
let active_peer_id = Uuid::new_v4();
|
||||
let active_peer = Peer {
|
||||
id: active_peer_id,
|
||||
interface_id: iface_id,
|
||||
name: "active-peer".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"activepubkey1234567890123456789012345678901=".to_string(),
|
||||
),
|
||||
private_key: None,
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.60.0.6/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some("10.60.0.6/32".parse().unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: None,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: Some(now + Duration::days(30)),
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.create_peer(&active_peer)
|
||||
.await
|
||||
.expect("create peer");
|
||||
|
||||
// Run reconciliation sweep
|
||||
let swept = reconciler.sweep_expired_peers().await.expect("sweep");
|
||||
assert_eq!(swept, 1);
|
||||
|
||||
// Verify expired_peer transitioned to Expired
|
||||
let p1 = state
|
||||
.store
|
||||
.get_peer(expired_peer_id)
|
||||
.await
|
||||
.expect("get")
|
||||
.unwrap();
|
||||
assert_eq!(p1.state, PeerState::Expired);
|
||||
|
||||
// Verify active_peer remains Active
|
||||
let p2 = state
|
||||
.store
|
||||
.get_peer(active_peer_id)
|
||||
.await
|
||||
.expect("get")
|
||||
.unwrap();
|
||||
assert_eq!(p2.state, PeerState::Active);
|
||||
|
||||
// Reconcile apply ensures only active peers are synced to WireGuard kernel engine
|
||||
let rep = reconciler.apply().await.expect("apply");
|
||||
assert!(rep.success);
|
||||
|
||||
let stats = wg_engine
|
||||
.get_interface_stats("wg60")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// Only active peer should be live in interface
|
||||
assert_eq!(stats.peers.len(), 1);
|
||||
assert_eq!(stats.peers[0].public_key, active_peer.public_key.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_peer_firewall_and_port_ranges() {
|
||||
let (state, _, net_engine, reconciler) = setup_test_context().await;
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface_id = Uuid::new_v4();
|
||||
let iface = Interface {
|
||||
id: iface_id,
|
||||
name: "wg70".to_string(),
|
||||
private_key: WireGuardPrivateKey::new(
|
||||
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
|
||||
),
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
|
||||
),
|
||||
listen_port: 51870,
|
||||
address_v4: "10.70.0.1/24".parse().unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: None,
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.create_interface(&iface)
|
||||
.await
|
||||
.expect("create iface");
|
||||
|
||||
let peer_id = Uuid::new_v4();
|
||||
let peer = Peer {
|
||||
id: peer_id,
|
||||
interface_id: iface_id,
|
||||
name: "dev-peer".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: WireGuardPublicKey::new(
|
||||
"devpeerpubkey1234567890123456789012345678901=".to_string(),
|
||||
),
|
||||
private_key: None,
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.70.0.10/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some("10.70.0.10/32".parse().unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: None,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.store.create_peer(&peer).await.expect("create peer");
|
||||
|
||||
// Peer-specific rule with multi-port and TCP/UDP protocol
|
||||
let rule = FirewallRule {
|
||||
id: Uuid::new_v4(),
|
||||
name: "Allow Dev Ports".to_string(),
|
||||
interface_id: Some(iface_id),
|
||||
peer_id: Some(peer_id),
|
||||
direction: FirewallDirection::Forward,
|
||||
action: FirewallAction::Accept,
|
||||
protocol: FirewallProtocol::TcpUdp,
|
||||
source: None,
|
||||
destination: None,
|
||||
source_port: None,
|
||||
destination_port: None,
|
||||
port_range: Some("8000-8100".to_string()),
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
description: Some("Peer port range".to_string()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.create_firewall_rule(&rule)
|
||||
.await
|
||||
.expect("create rule");
|
||||
|
||||
// Apply reconciliation to compile ruleset
|
||||
reconciler.apply().await.expect("apply");
|
||||
|
||||
let ruleset = net_engine
|
||||
.get_active_nftables_ruleset()
|
||||
.await
|
||||
.expect("get ruleset");
|
||||
assert!(ruleset.contains("table inet nx9_wg"));
|
||||
// Resolved peer IP 10.70.0.10, protocol meta l4proto { tcp, udp }, and port range 8000-8100
|
||||
assert!(ruleset.contains("ip saddr 10.70.0.10"));
|
||||
assert!(ruleset.contains("meta l4proto { tcp, udp }"));
|
||||
assert!(ruleset.contains("th dport 8000-8100 accept"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_native_diagnostics_subsystem() {
|
||||
let (state, wg_engine, net_engine, reconciler) = setup_test_context().await;
|
||||
let diag = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
|
||||
|
||||
let all_reports = diag.diagnose_all().await.expect("diagnose all");
|
||||
assert!(!all_reports.is_empty());
|
||||
|
||||
let sys_report = diag
|
||||
.run_diagnostic(DiagnosticSubsystem::System, None)
|
||||
.await
|
||||
.expect("diag system");
|
||||
assert_eq!(sys_report.len(), 1);
|
||||
assert_eq!(sys_report[0].subsystem, "system");
|
||||
|
||||
let fwd_report = diag
|
||||
.run_diagnostic(DiagnosticSubsystem::Forwarding, None)
|
||||
.await
|
||||
.expect("diag fwd");
|
||||
assert_eq!(fwd_report.len(), 1);
|
||||
assert_eq!(fwd_report[0].subsystem, "forwarding");
|
||||
}
|
||||
Reference in new issue
Block a user