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,836 @@
|
||||
//! Native diagnostics service for WireGuard, Linux networking, kernel sysctl, and reconciliation.
|
||||
|
||||
use crate::error::ApiResult;
|
||||
use crate::reconciliation::ReconciliationEngine;
|
||||
use crate::state::AppState;
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::diagnostics::{
|
||||
DiagnosticCheck, DiagnosticReport, DiagnosticStatus, DiagnosticSubsystem,
|
||||
};
|
||||
use nx9_wg_network::NetworkEngine;
|
||||
use nx9_wireguard::WireGuardEngine;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Native diagnostics inspection service.
|
||||
pub struct DiagnosticsService {
|
||||
state: AppState,
|
||||
wg_engine: Arc<dyn WireGuardEngine>,
|
||||
net_engine: Arc<dyn NetworkEngine>,
|
||||
reconciler: Arc<ReconciliationEngine>,
|
||||
}
|
||||
|
||||
impl DiagnosticsService {
|
||||
/// Create a new diagnostics service.
|
||||
pub fn new(
|
||||
state: AppState,
|
||||
wg_engine: Arc<dyn WireGuardEngine>,
|
||||
net_engine: Arc<dyn NetworkEngine>,
|
||||
reconciler: Arc<ReconciliationEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
state,
|
||||
wg_engine,
|
||||
net_engine,
|
||||
reconciler,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run diagnostic check for a target subsystem.
|
||||
pub async fn run_diagnostic(
|
||||
&self,
|
||||
subsystem: DiagnosticSubsystem,
|
||||
peer_id: Option<Uuid>,
|
||||
) -> ApiResult<Vec<DiagnosticReport>> {
|
||||
match subsystem {
|
||||
DiagnosticSubsystem::System => Ok(vec![self.diagnose_system().await?]),
|
||||
DiagnosticSubsystem::Network => Ok(vec![self.diagnose_network().await?]),
|
||||
DiagnosticSubsystem::Wan => Ok(vec![self.diagnose_wan().await?]),
|
||||
DiagnosticSubsystem::Wireguard => Ok(vec![self.diagnose_wireguard(None).await?]),
|
||||
DiagnosticSubsystem::Peer => {
|
||||
if let Some(id) = peer_id {
|
||||
Ok(vec![self.diagnose_peer(id).await?])
|
||||
} else {
|
||||
let peers = self.state.store.list_all_peers().await?;
|
||||
let mut reports = Vec::new();
|
||||
for p in peers {
|
||||
reports.push(self.diagnose_peer(p.id).await?);
|
||||
}
|
||||
if reports.is_empty() {
|
||||
reports.push(DiagnosticReport {
|
||||
subsystem: "peer".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: DiagnosticStatus::Pass,
|
||||
checks: vec![DiagnosticCheck {
|
||||
check_name: "enrolled_peers".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "0 peers".to_string(),
|
||||
expected_value: None,
|
||||
diagnostic_message:
|
||||
"No peers are currently enrolled in the database".to_string(),
|
||||
remediation_hint: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
Ok(reports)
|
||||
}
|
||||
}
|
||||
DiagnosticSubsystem::Routing => Ok(vec![self.diagnose_routing().await?]),
|
||||
DiagnosticSubsystem::Forwarding => Ok(vec![self.diagnose_forwarding().await?]),
|
||||
DiagnosticSubsystem::Firewall => Ok(vec![self.diagnose_firewall().await?]),
|
||||
DiagnosticSubsystem::Nat => Ok(vec![self.diagnose_nat().await?]),
|
||||
DiagnosticSubsystem::Mtu => Ok(vec![self.diagnose_mtu().await?]),
|
||||
DiagnosticSubsystem::Reconciliation => Ok(vec![self.diagnose_reconciliation().await?]),
|
||||
DiagnosticSubsystem::All => self.diagnose_all().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// System subsystem diagnostics.
|
||||
pub async fn diagnose_system(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
|
||||
// Hostname
|
||||
let hostname = std::fs::read_to_string("/etc/hostname")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "localhost".to_string());
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "hostname".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: hostname,
|
||||
expected_value: None,
|
||||
diagnostic_message: "System hostname read successfully".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
// OS and Architecture
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "os_architecture".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
expected_value: Some("linux-*".to_string()),
|
||||
diagnostic_message: "Supported target platform".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
// Kernel Version
|
||||
let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "Linux".to_string());
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "kernel_version".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: kernel,
|
||||
expected_value: None,
|
||||
diagnostic_message: "Linux kernel release inspected".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
// Memory Info
|
||||
if let Ok(mem) = std::fs::read_to_string("/proc/meminfo") {
|
||||
let mem_total = mem
|
||||
.lines()
|
||||
.find(|l| l.starts_with("MemTotal:"))
|
||||
.unwrap_or("MemTotal: unknown");
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "memory_status".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: mem_total.to_string(),
|
||||
expected_value: None,
|
||||
diagnostic_message: "System memory available".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Database Health Check
|
||||
let db_health = self.state.store.health_check().await;
|
||||
match db_health {
|
||||
Ok(_) => checks.push(DiagnosticCheck {
|
||||
check_name: "sqlite_persistence".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "connected_and_healthy".to_string(),
|
||||
expected_value: Some("connected_and_healthy".to_string()),
|
||||
diagnostic_message: "SQLite WAL persistence layer is responsive".to_string(),
|
||||
remediation_hint: None,
|
||||
}),
|
||||
Err(e) => checks.push(DiagnosticCheck {
|
||||
check_name: "sqlite_persistence".to_string(),
|
||||
status: DiagnosticStatus::Fail,
|
||||
observed_value: format!("error: {e}"),
|
||||
expected_value: Some("connected_and_healthy".to_string()),
|
||||
diagnostic_message: "Database connectivity failure".to_string(),
|
||||
remediation_hint: Some(
|
||||
"Verify database file permissions and disk space".to_string(),
|
||||
),
|
||||
}),
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "system".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Network subsystem diagnostics.
|
||||
pub async fn diagnose_network(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
|
||||
// Interface device list
|
||||
if let Ok(devs) = std::fs::read_to_string("/proc/net/dev") {
|
||||
let iface_names: Vec<String> = devs
|
||||
.lines()
|
||||
.skip(2)
|
||||
.filter_map(|l| l.split(':').next().map(|s| s.trim().to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "linux_network_interfaces".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: format!(
|
||||
"{} interfaces ({})",
|
||||
iface_names.len(),
|
||||
iface_names.join(", ")
|
||||
),
|
||||
expected_value: None,
|
||||
diagnostic_message: "Network interfaces discovered in kernel".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
}
|
||||
|
||||
// DNS Configuration
|
||||
let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
|
||||
let nameservers: Vec<&str> = resolv
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("nameserver"))
|
||||
.filter_map(|l| l.split_whitespace().nth(1))
|
||||
.collect();
|
||||
|
||||
if nameservers.is_empty() {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "dns_nameservers".to_string(),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: "none_configured".to_string(),
|
||||
expected_value: Some("valid nameserver entries".to_string()),
|
||||
diagnostic_message: "No DNS nameservers found in /etc/resolv.conf".to_string(),
|
||||
remediation_hint: Some(
|
||||
"Configure DNS servers in /etc/resolv.conf or interface settings".to_string(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "dns_nameservers".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: nameservers.join(", "),
|
||||
expected_value: None,
|
||||
diagnostic_message: "System DNS nameservers configured".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "network".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// WAN and external reachability diagnostics.
|
||||
pub async fn diagnose_wan(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
|
||||
// Default Route check
|
||||
let routes = std::fs::read_to_string("/proc/net/route").unwrap_or_default();
|
||||
let has_default_gateway = routes.lines().skip(1).any(|l| {
|
||||
let cols: Vec<&str> = l.split_whitespace().collect();
|
||||
cols.len() > 1 && cols[1] == "00000000"
|
||||
});
|
||||
|
||||
if has_default_gateway {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "default_gateway_route".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "default_gateway_present".to_string(),
|
||||
expected_value: Some("default_gateway_present".to_string()),
|
||||
diagnostic_message: "Default route to WAN/gateway is present".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
} else {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "default_gateway_route".to_string(),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: "missing_default_gateway".to_string(),
|
||||
expected_value: Some("default_gateway_present".to_string()),
|
||||
diagnostic_message:
|
||||
"No default gateway (0.0.0.0/0) detected in kernel routing table".to_string(),
|
||||
remediation_hint: Some(
|
||||
"Verify network connection or add a default route using 'nx9-wg route add'"
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "wan".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// WireGuard interface diagnostics.
|
||||
pub async fn diagnose_wireguard(
|
||||
&self,
|
||||
interface_name: Option<&str>,
|
||||
) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let interfaces = self.state.store.list_interfaces().await?;
|
||||
|
||||
if interfaces.is_empty() {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "configured_interfaces".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "0 interfaces".to_string(),
|
||||
expected_value: None,
|
||||
diagnostic_message: "No WireGuard interfaces configured yet".to_string(),
|
||||
remediation_hint: Some(
|
||||
"Create an interface using 'nx9-wg interface create'".to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for iface in &interfaces {
|
||||
if interface_name.is_some_and(|target| iface.name != target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let live_stats = self
|
||||
.wg_engine
|
||||
.get_interface_stats(&iface.name)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match live_stats {
|
||||
Some(stats) => {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("interface_{}_status", iface.name),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: format!(
|
||||
"active: port {}, peers {}",
|
||||
stats.listen_port,
|
||||
stats.peers.len()
|
||||
),
|
||||
expected_value: Some(format!("port {}", iface.listen_port)),
|
||||
diagnostic_message: format!(
|
||||
"Interface '{}' is running and responsive",
|
||||
iface.name
|
||||
),
|
||||
remediation_hint: None,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
if iface.enabled {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("interface_{}_status", iface.name),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: "down_or_uninitialized".to_string(),
|
||||
expected_value: Some("running".to_string()),
|
||||
diagnostic_message: format!(
|
||||
"Interface '{}' is enabled in database but not active in kernel",
|
||||
iface.name
|
||||
),
|
||||
remediation_hint: Some(
|
||||
"Run 'nx9-wg reconcile apply' to synchronize interface to kernel"
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("interface_{}_status", iface.name),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "administratively_disabled".to_string(),
|
||||
expected_value: Some("disabled".to_string()),
|
||||
diagnostic_message: format!(
|
||||
"Interface '{}' is disabled as intended",
|
||||
iface.name
|
||||
),
|
||||
remediation_hint: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "wireguard".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Single peer diagnostics.
|
||||
pub async fn diagnose_peer(&self, peer_id: Uuid) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let peer = self.state.store.get_peer(peer_id).await?;
|
||||
|
||||
match peer {
|
||||
Some(p) => {
|
||||
// Peer State
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "lifecycle_state".to_string(),
|
||||
status: match p.state {
|
||||
nx9_wg_core::types::wireguard::PeerState::Active => DiagnosticStatus::Pass,
|
||||
nx9_wg_core::types::wireguard::PeerState::Disabled => {
|
||||
DiagnosticStatus::Warning
|
||||
}
|
||||
nx9_wg_core::types::wireguard::PeerState::Expired => {
|
||||
DiagnosticStatus::Warning
|
||||
}
|
||||
nx9_wg_core::types::wireguard::PeerState::Revoked => DiagnosticStatus::Fail,
|
||||
},
|
||||
observed_value: p.state.to_string(),
|
||||
expected_value: Some("active".to_string()),
|
||||
diagnostic_message: format!("Peer '{}' is in '{}' state", p.name, p.state),
|
||||
remediation_hint: match p.state {
|
||||
nx9_wg_core::types::wireguard::PeerState::Expired => {
|
||||
Some("Extend or renew peer expiration date".to_string())
|
||||
}
|
||||
nx9_wg_core::types::wireguard::PeerState::Disabled => {
|
||||
Some("Enable peer using 'nx9-wg peer enable'".to_string())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
});
|
||||
|
||||
// Address allocation
|
||||
let v4_str = p
|
||||
.address_v4
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "assigned_address".to_string(),
|
||||
status: if p.address_v4.is_some() {
|
||||
DiagnosticStatus::Pass
|
||||
} else {
|
||||
DiagnosticStatus::Warning
|
||||
},
|
||||
observed_value: v4_str,
|
||||
expected_value: Some("valid CIDR".to_string()),
|
||||
diagnostic_message: format!(
|
||||
"Peer address assignment: allowed_ips={}",
|
||||
p.allowed_ips
|
||||
),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
// Expiration timeline
|
||||
if let Some(exp) = p.expires_at {
|
||||
let now = Utc::now().naive_utc();
|
||||
if exp <= now {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "expiration_status".to_string(),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: format!("expired_at_{exp}"),
|
||||
expected_value: Some("future_expiration".to_string()),
|
||||
diagnostic_message: "Peer expiration timestamp has elapsed".to_string(),
|
||||
remediation_hint: Some(
|
||||
"Update peer expiration date to restore access".to_string(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "expiration_status".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: format!("valid_until_{exp}"),
|
||||
expected_value: None,
|
||||
diagnostic_message: "Peer credential is within validity period"
|
||||
.to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "peer_lookup".to_string(),
|
||||
status: DiagnosticStatus::Fail,
|
||||
observed_value: "not_found".to_string(),
|
||||
expected_value: Some("valid_peer_record".to_string()),
|
||||
diagnostic_message: format!(
|
||||
"Peer '{peer_id}' does not exist in SQLite database"
|
||||
),
|
||||
remediation_hint: Some("Verify peer ID with 'nx9-wg peer list'".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: format!("peer:{}", peer_id),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Routing subsystem diagnostics.
|
||||
pub async fn diagnose_routing(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let routes = self.state.store.list_routes().await?;
|
||||
let active_routes: Vec<_> = routes.iter().filter(|r| r.enabled).collect();
|
||||
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "configured_routes".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: format!("{} total ({} active)", routes.len(), active_routes.len()),
|
||||
expected_value: None,
|
||||
diagnostic_message: "Kernel routing rules configured in database".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "routing".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// IP packet forwarding diagnostics.
|
||||
pub async fn diagnose_forwarding(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let fwd = self.net_engine.get_forwarding_status().await;
|
||||
|
||||
match fwd {
|
||||
Ok(status) => {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "ipv4_forwarding".to_string(),
|
||||
status: if status.ipv4_enabled { DiagnosticStatus::Pass } else { DiagnosticStatus::Warning },
|
||||
observed_value: if status.ipv4_enabled { "enabled".to_string() } else { "disabled".to_string() },
|
||||
expected_value: Some("enabled".to_string()),
|
||||
diagnostic_message: if status.ipv4_enabled {
|
||||
"IPv4 packet forwarding is enabled in sysctl".to_string()
|
||||
} else {
|
||||
"IPv4 packet forwarding is disabled in sysctl; VPN clients cannot route traffic".to_string()
|
||||
},
|
||||
remediation_hint: if !status.ipv4_enabled {
|
||||
Some("Enable IP forwarding with 'nx9-wg forwarding enable'".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "forwarding_sysctl_read".to_string(),
|
||||
status: DiagnosticStatus::Fail,
|
||||
observed_value: format!("error: {e}"),
|
||||
expected_value: Some("readable".to_string()),
|
||||
diagnostic_message: "Failed to read kernel forwarding state".to_string(),
|
||||
remediation_hint: Some("Verify /proc filesystem is mounted".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "forwarding".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Firewall subsystem diagnostics.
|
||||
pub async fn diagnose_firewall(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let rules = self.state.store.list_firewall_rules().await?;
|
||||
let active_rules: Vec<_> = rules.iter().filter(|r| r.enabled).collect();
|
||||
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "firewall_rules_count".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: format!("{} total ({} active)", rules.len(), active_rules.len()),
|
||||
expected_value: None,
|
||||
diagnostic_message: "Configured nftables packet filtering rules".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
let active_nft = self.net_engine.get_active_nftables_ruleset().await;
|
||||
match active_nft {
|
||||
Ok(ruleset) => {
|
||||
let has_table = ruleset.contains("table inet nx9_wg");
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "nftables_table_nx9_wg".to_string(),
|
||||
status: if has_table {
|
||||
DiagnosticStatus::Pass
|
||||
} else {
|
||||
DiagnosticStatus::Warning
|
||||
},
|
||||
observed_value: if has_table {
|
||||
"active".to_string()
|
||||
} else {
|
||||
"not_loaded".to_string()
|
||||
},
|
||||
expected_value: Some("active".to_string()),
|
||||
diagnostic_message: "Dedicated table inet nx9_wg presence in kernel nftables"
|
||||
.to_string(),
|
||||
remediation_hint: if !has_table {
|
||||
Some("Synchronize firewall with 'nx9-wg firewall sync'".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "nftables_access".to_string(),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: format!("error: {e}"),
|
||||
expected_value: Some("accessible".to_string()),
|
||||
diagnostic_message: "Could not inspect live nftables ruleset".to_string(),
|
||||
remediation_hint: Some("Verify CAP_NET_ADMIN / root permissions".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "firewall".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// NAT masquerade diagnostics.
|
||||
pub async fn diagnose_nat(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let nat_setting = self
|
||||
.state
|
||||
.store
|
||||
.get_setting("enable_nat")
|
||||
.await?
|
||||
.map(|s| s.value == "true" || s.value == "1")
|
||||
.unwrap_or(true);
|
||||
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "nat_setting".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: if nat_setting {
|
||||
"enabled".to_string()
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
},
|
||||
expected_value: None,
|
||||
diagnostic_message: "NAT masquerade setting configured in database".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "nat".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// MTU consistency and client profile diagnostics.
|
||||
pub async fn diagnose_mtu(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let interfaces = self.state.store.list_interfaces().await?;
|
||||
let peers = self.state.store.list_all_peers().await?;
|
||||
|
||||
// 1. Interface MTU Checks
|
||||
for iface in &interfaces {
|
||||
let mtu = iface.mtu.unwrap_or(1420);
|
||||
if mtu > 1500 {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("server_mtu_{}", iface.name),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: format!("{mtu} bytes (jumbo)"),
|
||||
expected_value: Some("1420 bytes (<= 1500)".to_string()),
|
||||
diagnostic_message: format!(
|
||||
"Interface '{}' MTU ({mtu}) exceeds standard physical MTU 1500; may cause fragmentation on WAN egress",
|
||||
iface.name
|
||||
),
|
||||
remediation_hint: Some(
|
||||
"Set WireGuard server MTU to 1420 to prevent packet fragmentation".to_string(),
|
||||
),
|
||||
});
|
||||
} else if mtu < 1280 {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("server_mtu_{}", iface.name),
|
||||
status: DiagnosticStatus::Fail,
|
||||
observed_value: format!("{mtu} bytes"),
|
||||
expected_value: Some(">= 1280 bytes".to_string()),
|
||||
diagnostic_message: format!(
|
||||
"Interface '{}' MTU ({mtu}) is below the IPv6 minimum MTU (1280)",
|
||||
iface.name
|
||||
),
|
||||
remediation_hint: Some(
|
||||
"Increase interface MTU to at least 1280 bytes".to_string(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("server_mtu_{}", iface.name),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: format!("{mtu} bytes"),
|
||||
expected_value: None,
|
||||
diagnostic_message: format!(
|
||||
"Server interface '{}' MTU ({mtu}) is within safe WAN limits (1280-1500)",
|
||||
iface.name
|
||||
),
|
||||
remediation_hint: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Check peer MTU consistency against server MTU
|
||||
let iface_peers: Vec<_> = peers
|
||||
.iter()
|
||||
.filter(|p| p.interface_id == iface.id)
|
||||
.collect();
|
||||
for p in iface_peers {
|
||||
if let Some(peer_mtu) = p.mtu.filter(|&pm| pm > mtu) {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("peer_mtu_{}", p.name),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: format!("{peer_mtu} bytes"),
|
||||
expected_value: Some(format!("<= {mtu} bytes")),
|
||||
diagnostic_message: format!(
|
||||
"Peer '{}' MTU ({peer_mtu}) exceeds server interface '{}' MTU ({mtu})",
|
||||
p.name, iface.name
|
||||
),
|
||||
remediation_hint: Some(
|
||||
"Align peer MTU to be equal to or less than server interface MTU"
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Client Profile Recommendations Check
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "client_profile_mobile_recommendation".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "1280 bytes (keepalive: 25s)".to_string(),
|
||||
expected_value: Some("1280 bytes".to_string()),
|
||||
diagnostic_message: "Recommended MTU for mobile/cellular connections is 1280 to prevent carrier fragmentation".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "client_profile_cgnat_recommendation".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "1360 bytes (keepalive: 25s)".to_string(),
|
||||
expected_value: Some("1360 bytes".to_string()),
|
||||
diagnostic_message: "Recommended MTU for CGNAT connections is 1360 to accommodate carrier-grade NAT encapsulation".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "client_profile_wifi_recommendation".to_string(),
|
||||
status: DiagnosticStatus::Pass,
|
||||
observed_value: "1420 bytes (keepalive: 25s)".to_string(),
|
||||
expected_value: Some("1420 bytes".to_string()),
|
||||
diagnostic_message: "Recommended MTU for standard Wi-Fi and wired connections is 1420 bytes".to_string(),
|
||||
remediation_hint: None,
|
||||
});
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "mtu".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconciliation drift diagnostics.
|
||||
pub async fn diagnose_reconciliation(&self) -> ApiResult<DiagnosticReport> {
|
||||
let mut checks = Vec::new();
|
||||
let plan = self.reconciler.plan().await?;
|
||||
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: "overall_drift".to_string(),
|
||||
status: if plan.has_drift {
|
||||
DiagnosticStatus::Warning
|
||||
} else {
|
||||
DiagnosticStatus::Pass
|
||||
},
|
||||
observed_value: if plan.has_drift {
|
||||
format!("{} drift actions pending", plan.actions.len())
|
||||
} else {
|
||||
"zero_drift".to_string()
|
||||
},
|
||||
expected_value: Some("zero_drift".to_string()),
|
||||
diagnostic_message: if plan.has_drift {
|
||||
"Discrepancies detected between SQLite desired state and Linux kernel state"
|
||||
.to_string()
|
||||
} else {
|
||||
"SQLite desired state and live kernel state are in full synchronization".to_string()
|
||||
},
|
||||
remediation_hint: if plan.has_drift {
|
||||
Some("Execute 'nx9-wg reconcile apply' to synchronize changes".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
|
||||
for action in plan.actions {
|
||||
checks.push(DiagnosticCheck {
|
||||
check_name: format!("drift:{}:{}", action.subsystem, action.action_type),
|
||||
status: DiagnosticStatus::Warning,
|
||||
observed_value: action.resource_id,
|
||||
expected_value: None,
|
||||
diagnostic_message: action.description,
|
||||
remediation_hint: Some("Run 'nx9-wg reconcile apply'".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let overall = Self::calculate_overall_status(&checks);
|
||||
Ok(DiagnosticReport {
|
||||
subsystem: "reconciliation".to_string(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
overall_status: overall,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run full diagnosis across all subsystems.
|
||||
pub async fn diagnose_all(&self) -> ApiResult<Vec<DiagnosticReport>> {
|
||||
let mut reports = Vec::new();
|
||||
reports.push(self.diagnose_system().await?);
|
||||
reports.push(self.diagnose_network().await?);
|
||||
reports.push(self.diagnose_wan().await?);
|
||||
reports.push(self.diagnose_wireguard(None).await?);
|
||||
reports.push(self.diagnose_routing().await?);
|
||||
reports.push(self.diagnose_forwarding().await?);
|
||||
reports.push(self.diagnose_firewall().await?);
|
||||
reports.push(self.diagnose_nat().await?);
|
||||
reports.push(self.diagnose_mtu().await?);
|
||||
reports.push(self.diagnose_reconciliation().await?);
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
fn calculate_overall_status(checks: &[DiagnosticCheck]) -> DiagnosticStatus {
|
||||
if checks.iter().any(|c| c.status == DiagnosticStatus::Fail) {
|
||||
DiagnosticStatus::Fail
|
||||
} else if checks.iter().any(|c| c.status == DiagnosticStatus::Warning) {
|
||||
DiagnosticStatus::Warning
|
||||
} else {
|
||||
DiagnosticStatus::Pass
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user