Files
nx9-wg/crates/nx9-wg-api/src/diagnostics.rs
T
2026-09-02 15:19:19 +05:30

842 lines
35 KiB
Rust

//! 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(
iface
.listen_port
.map(|p| format!("port {p}"))
.unwrap_or_else(|| "port auto".to_string()),
),
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
}
}
}