842 lines
35 KiB
Rust
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
|
|
}
|
|
}
|
|
}
|