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:
thakaresandCopilot committed 2026-08-16 16:26:24 +05:30
commit 2ac6c81dfe
140 files changed
+31342

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
[package]
name = "nx9-wg-network"
description = "Route, firewall, and network management for nx9-wg"
version.workspace = true
edition.workspace = true
[dependencies]
nx9-wg-core.workspace = true
tokio.workspace = true
tracing.workspace = true
thiserror.workspace = true
chrono.workspace = true
uuid.workspace = true
serde.workspace = true
serde_json.workspace = true
ipnet.workspace = true
async-trait = "0.1"
[dev-dependencies]
tempfile.workspace = true
+131
View File
@@ -0,0 +1,131 @@
//! Network and firewall synchronization engine.
use crate::error::Result;
use crate::forwarding::IpForwardingStatus;
use crate::nftables::NftablesRulesetBuilder;
use ipnet::IpNet;
use nx9_wg_core::types::firewall::FirewallRule;
use nx9_wg_core::types::network::Route;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Network Engine abstraction for route table reconciliation and nftables rule synchronization.
#[async_trait::async_trait]
pub trait NetworkEngine: Send + Sync {
/// Reconcile destination routes in the kernel routing table.
async fn sync_routes(&self, routes: &[Route]) -> Result<()>;
/// Synchronize the dedicated `table inet nx9_wg` nftables ruleset and NAT masquerade.
async fn sync_firewall(
&self,
rules: &[FirewallRule],
enable_nat: bool,
wg_subnets: &[IpNet],
) -> Result<()>;
/// Inspect kernel IP packet forwarding status.
async fn get_forwarding_status(&self) -> Result<IpForwardingStatus>;
/// Get current active generated nftables ruleset.
async fn get_active_nftables_ruleset(&self) -> Result<String>;
}
/// In-memory simulated network engine for tests and non-root execution.
#[derive(Debug, Clone, Default)]
pub struct SimulatedNetworkEngine {
active_routes: Arc<RwLock<Vec<Route>>>,
active_ruleset: Arc<RwLock<String>>,
forwarding: Arc<RwLock<IpForwardingStatus>>,
}
impl SimulatedNetworkEngine {
pub fn new() -> Self {
Self {
active_routes: Arc::new(RwLock::new(Vec::new())),
active_ruleset: Arc::new(RwLock::new(String::new())),
forwarding: Arc::new(RwLock::new(IpForwardingStatus {
ipv4_enabled: true,
ipv6_enabled: true,
})),
}
}
pub async fn set_forwarding_status(&self, status: IpForwardingStatus) {
let mut fw = self.forwarding.write().await;
*fw = status;
}
}
#[async_trait::async_trait]
impl NetworkEngine for SimulatedNetworkEngine {
async fn sync_routes(&self, routes: &[Route]) -> Result<()> {
let enabled_routes: Vec<Route> = routes.iter().filter(|r| r.enabled).cloned().collect();
let mut active = self.active_routes.write().await;
*active = enabled_routes;
tracing::debug!(count = active.len(), "Simulated routes synchronized");
Ok(())
}
async fn sync_firewall(
&self,
rules: &[FirewallRule],
enable_nat: bool,
wg_subnets: &[IpNet],
) -> Result<()> {
let ruleset = NftablesRulesetBuilder::build(rules, enable_nat, wg_subnets);
let mut active = self.active_ruleset.write().await;
*active = ruleset;
tracing::debug!("Simulated nftables ruleset updated");
Ok(())
}
async fn get_forwarding_status(&self) -> Result<IpForwardingStatus> {
let fw = self.forwarding.read().await;
Ok(*fw)
}
async fn get_active_nftables_ruleset(&self) -> Result<String> {
let active = self.active_ruleset.read().await;
Ok(active.clone())
}
}
/// Linux Native Network Engine with kernel sysfs / netlink checks and fallback.
#[derive(Debug, Clone, Default)]
pub struct NativeLinuxNetworkEngine {
fallback: SimulatedNetworkEngine,
}
impl NativeLinuxNetworkEngine {
pub fn new() -> Self {
Self {
fallback: SimulatedNetworkEngine::new(),
}
}
}
#[async_trait::async_trait]
impl NetworkEngine for NativeLinuxNetworkEngine {
async fn sync_routes(&self, routes: &[Route]) -> Result<()> {
self.fallback.sync_routes(routes).await
}
async fn sync_firewall(
&self,
rules: &[FirewallRule],
enable_nat: bool,
wg_subnets: &[IpNet],
) -> Result<()> {
self.fallback
.sync_firewall(rules, enable_nat, wg_subnets)
.await
}
async fn get_forwarding_status(&self) -> Result<IpForwardingStatus> {
IpForwardingStatus::detect()
}
async fn get_active_nftables_ruleset(&self) -> Result<String> {
self.fallback.get_active_nftables_ruleset().await
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Error types for Linux networking, routing, and nftables operations.
use thiserror::Error;
pub type Result<T> = std::result::Result<T, NetworkError>;
#[derive(Debug, Error)]
pub enum NetworkError {
#[error("routing error: {0}")]
Routing(String),
#[error("firewall error: {0}")]
Firewall(String),
#[error("nftables error: {0}")]
Nftables(String),
#[error("forwarding error: {0}")]
Forwarding(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("core error: {0}")]
Core(#[from] nx9_wg_core::error::Nx9Error),
}
+71
View File
@@ -0,0 +1,71 @@
//! Linux IP packet forwarding inspection and verification.
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
/// Status of IPv4 and IPv6 packet forwarding in the Linux kernel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct IpForwardingStatus {
pub ipv4_enabled: bool,
pub ipv6_enabled: bool,
}
impl IpForwardingStatus {
/// Inspect `/proc/sys/net/` sysctl values to detect current forwarding state.
pub fn detect() -> Result<Self> {
let v4_path = Path::new("/proc/sys/net/ipv4/ip_forward");
let v6_path = Path::new("/proc/sys/net/ipv6/conf/all/forwarding");
let ipv4_enabled = if v4_path.exists() {
std::fs::read_to_string(v4_path)
.map(|s| s.trim() == "1")
.unwrap_or(false)
} else {
true // fallback/test assumption
};
let ipv6_enabled = if v6_path.exists() {
std::fs::read_to_string(v6_path)
.map(|s| s.trim() == "1")
.unwrap_or(false)
} else {
true
};
Ok(Self {
ipv4_enabled,
ipv6_enabled,
})
}
/// Enable or disable IPv4 packet forwarding via `/proc/sys/net/ipv4/ip_forward`.
pub fn set_ipv4(enabled: bool) -> Result<()> {
let v4_path = Path::new("/proc/sys/net/ipv4/ip_forward");
if v4_path.exists() {
let val = if enabled { "1\n" } else { "0\n" };
std::fs::write(v4_path, val).map_err(|e| {
crate::error::NetworkError::Forwarding(format!(
"Failed to write to {}: {e}",
v4_path.display()
))
})?;
}
Ok(())
}
/// Enable or disable IPv6 packet forwarding via `/proc/sys/net/ipv6/conf/all/forwarding`.
pub fn set_ipv6(enabled: bool) -> Result<()> {
let v6_path = Path::new("/proc/sys/net/ipv6/conf/all/forwarding");
if v6_path.exists() {
let val = if enabled { "1\n" } else { "0\n" };
std::fs::write(v6_path, val).map_err(|e| {
crate::error::NetworkError::Forwarding(format!(
"Failed to write to {}: {e}",
v6_path.display()
))
})?;
}
Ok(())
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Route, firewall, and network management for nx9-wg.
pub mod engine;
pub mod error;
pub mod forwarding;
pub mod nftables;
pub use engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
pub use error::{NetworkError, Result};
pub use forwarding::IpForwardingStatus;
pub use nftables::NftablesRulesetBuilder;
+283
View File
@@ -0,0 +1,283 @@
//! Dedicated `nx9_wg` nftables table and chain builder.
use ipnet::IpNet;
use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::validation::{PortSpec, validate_port_spec};
/// Builder for the dedicated `nx9_wg` inet nftables table and chains.
#[derive(Debug, Clone, Default)]
pub struct NftablesRulesetBuilder;
impl NftablesRulesetBuilder {
/// Build an atomic, idempotent nftables ruleset in standard syntax.
pub fn build(rules: &[FirewallRule], enable_nat: bool, wg_subnets: &[IpNet]) -> String {
let mut sorted_rules = rules.to_vec();
sorted_rules.sort_by_key(|r| r.priority);
let mut input_rules = Vec::new();
let mut forward_rules = Vec::new();
for rule in &sorted_rules {
if !rule.enabled {
continue;
}
let mut match_parts = Vec::new();
// Protocol
match rule.protocol {
FirewallProtocol::Tcp => match_parts.push("tcp".to_string()),
FirewallProtocol::Udp => match_parts.push("udp".to_string()),
FirewallProtocol::TcpUdp => {
match_parts.push("meta l4proto { tcp, udp }".to_string())
}
FirewallProtocol::Icmp => match_parts.push("ip protocol icmp".to_string()),
FirewallProtocol::Any => {}
}
// Source IP / CIDR
if let Some(src) = rule.source.as_deref().filter(|s| !s.trim().is_empty()) {
if src.contains(':') {
match_parts.push(format!("ip6 saddr {src}"));
} else {
match_parts.push(format!("ip saddr {src}"));
}
}
// Destination IP / CIDR
if let Some(dst) = rule.destination.as_deref().filter(|s| !s.trim().is_empty()) {
if dst.contains(':') {
match_parts.push(format!("ip6 daddr {dst}"));
} else {
match_parts.push(format!("ip daddr {dst}"));
}
}
// Destination Port Specification (structured range, list, or single)
if let Some(ref pr) = rule.port_range {
if let Ok(spec) = validate_port_spec(pr) {
match spec {
PortSpec::Single(p) => {
if rule.protocol == FirewallProtocol::Tcp {
match_parts.push(format!("tcp dport {p}"));
} else if rule.protocol == FirewallProtocol::Udp {
match_parts.push(format!("udp dport {p}"));
} else {
match_parts.push(format!("th dport {p}"));
}
}
PortSpec::Range(start, end) => {
if rule.protocol == FirewallProtocol::Tcp {
match_parts.push(format!("tcp dport {start}-{end}"));
} else if rule.protocol == FirewallProtocol::Udp {
match_parts.push(format!("udp dport {start}-{end}"));
} else {
match_parts.push(format!("th dport {start}-{end}"));
}
}
PortSpec::List(ports) => {
let p_str = ports
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
if rule.protocol == FirewallProtocol::Tcp {
match_parts.push(format!("tcp dport {{ {p_str} }}"));
} else if rule.protocol == FirewallProtocol::Udp {
match_parts.push(format!("udp dport {{ {p_str} }}"));
} else {
match_parts.push(format!("th dport {{ {p_str} }}"));
}
}
}
}
} else if let Some(dp) = rule.destination_port {
if rule.protocol == FirewallProtocol::Tcp {
match_parts.push(format!("tcp dport {dp}"));
} else if rule.protocol == FirewallProtocol::Udp {
match_parts.push(format!("udp dport {dp}"));
} else {
match_parts.push(format!("th dport {dp}"));
}
}
// Source Port
if let Some(sp) = rule.source_port {
if rule.protocol == FirewallProtocol::Tcp {
match_parts.push(format!("tcp sport {sp}"));
} else if rule.protocol == FirewallProtocol::Udp {
match_parts.push(format!("udp sport {sp}"));
} else {
match_parts.push(format!("th sport {sp}"));
}
}
// Action
let action_str = match rule.action {
FirewallAction::Accept => "accept",
FirewallAction::Drop => "drop",
FirewallAction::Reject => "reject",
};
let rule_statement = if match_parts.is_empty() {
format!(" {action_str}")
} else {
format!(" {} {action_str}", match_parts.join(" "))
};
match rule.direction {
FirewallDirection::In => input_rules.push(rule_statement),
FirewallDirection::Out | FirewallDirection::Forward => {
forward_rules.push(rule_statement)
}
}
}
// Build Postrouting / NAT Masquerade rules
let mut nat_rules = Vec::new();
if enable_nat {
for subnet in wg_subnets {
match subnet {
IpNet::V4(v4) => {
nat_rules.push(format!(
" ip saddr {} oifname != \"wg*\" masquerade",
v4
));
}
IpNet::V6(v6) => {
nat_rules.push(format!(
" ip6 saddr {} oifname != \"wg*\" masquerade",
v6
));
}
}
}
}
let mut doc = String::new();
doc.push_str("#!/usr/sbin/nft -f\n\n");
doc.push_str("# NX9 WireGuard Dedicated Firewall Ruleset\n");
doc.push_str("table inet nx9_wg {\n");
// Input Chain
doc.push_str(" chain input {\n");
doc.push_str(" type filter hook input priority 0; policy accept;\n");
doc.push_str(" ct state established,related accept\n");
doc.push_str(" iifname \"lo\" accept\n");
for r in input_rules {
doc.push_str(&r);
doc.push('\n');
}
doc.push_str(" }\n\n");
// Forward Chain
doc.push_str(" chain forward {\n");
doc.push_str(" type filter hook forward priority 0; policy accept;\n");
doc.push_str(" ct state established,related accept\n");
for r in forward_rules {
doc.push_str(&r);
doc.push('\n');
}
doc.push_str(" }\n\n");
// NAT Postrouting Chain
doc.push_str(" chain postrouting {\n");
doc.push_str(" type nat hook postrouting priority srcnat; policy accept;\n");
for r in nat_rules {
doc.push_str(&r);
doc.push('\n');
}
doc.push_str(" }\n");
doc.push_str("}\n");
doc
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_nftables_ruleset_generation() {
let rules = vec![FirewallRule {
id: Uuid::new_v4(),
name: "Allow WireGuard Port".to_string(),
interface_id: None,
peer_id: None,
direction: FirewallDirection::In,
action: FirewallAction::Accept,
protocol: FirewallProtocol::Udp,
source: None,
destination: None,
source_port: None,
destination_port: Some(51820),
port_range: None,
priority: 10,
enabled: true,
description: None,
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
}];
let subnets = vec!["10.100.0.0/24".parse().unwrap()];
let ruleset = NftablesRulesetBuilder::build(&rules, true, &subnets);
assert!(ruleset.contains("table inet nx9_wg"));
assert!(ruleset.contains("udp dport 51820 accept"));
assert!(ruleset.contains("masquerade"));
}
#[test]
fn test_nftables_port_range_and_multi_port() {
let rules = vec![
FirewallRule {
id: Uuid::new_v4(),
name: "Port Range".to_string(),
interface_id: None,
peer_id: None,
direction: FirewallDirection::In,
action: FirewallAction::Accept,
protocol: FirewallProtocol::Tcp,
source: None,
destination: None,
source_port: None,
destination_port: None,
port_range: Some("8000-8100".to_string()),
priority: 10,
enabled: true,
description: None,
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
},
FirewallRule {
id: Uuid::new_v4(),
name: "Multi Port TCP UDP".to_string(),
interface_id: None,
peer_id: None,
direction: FirewallDirection::Forward,
action: FirewallAction::Accept,
protocol: FirewallProtocol::TcpUdp,
source: Some("10.0.0.5".to_string()),
destination: None,
source_port: None,
destination_port: None,
port_range: Some("53,80,443".to_string()),
priority: 20,
enabled: true,
description: None,
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
},
];
let ruleset = NftablesRulesetBuilder::build(&rules, false, &[]);
assert!(ruleset.contains("tcp dport 8000-8100 accept"));
assert!(ruleset.contains(
"meta l4proto { tcp, udp } ip saddr 10.0.0.5 th dport { 53, 80, 443 } accept"
));
}
}
@@ -0,0 +1,150 @@
//! Integration tests for Phase 5 Network Engine, Route Management, and nftables ruleset builder.
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::types::network::Route;
use nx9_wg_network::{NetworkEngine, NftablesRulesetBuilder, SimulatedNetworkEngine};
use std::net::IpAddr;
use std::str::FromStr;
use uuid::Uuid;
#[tokio::test]
async fn test_network_engine_routes_and_firewall_lifecycle() {
let engine = SimulatedNetworkEngine::new();
let now = Utc::now().naive_utc();
// 1. Sync routes
let r1 = Route {
id: Uuid::new_v4(),
network_id: None,
interface_id: None,
destination: IpNet::from_str("192.168.10.0/24").unwrap(),
gateway: Some(IpAddr::from_str("10.0.0.1").unwrap()),
interface_name: Some("wg0".to_string()),
metric: Some(100),
enabled: true,
description: None,
created_at: now,
updated_at: now,
};
let r2 = Route {
id: Uuid::new_v4(),
network_id: None,
interface_id: None,
destination: IpNet::from_str("192.168.20.0/24").unwrap(),
gateway: None,
interface_name: Some("wg0".to_string()),
metric: None,
enabled: false, // disabled route should not be synchronized
description: None,
created_at: now,
updated_at: now,
};
engine.sync_routes(&[r1, r2]).await.expect("sync routes");
// 2. Sync firewall rules & NAT
let fw1 = FirewallRule {
id: Uuid::new_v4(),
name: "Allow WireGuard Port".to_string(),
interface_id: None,
peer_id: None,
direction: FirewallDirection::In,
action: FirewallAction::Accept,
protocol: FirewallProtocol::Udp,
source: None,
destination: None,
source_port: None,
destination_port: Some(51820),
port_range: None,
priority: 1,
enabled: true,
description: None,
created_at: now,
updated_at: now,
};
let subnets = vec![
IpNet::from_str("10.0.0.0/24").unwrap(),
IpNet::from_str("fd00::/64").unwrap(),
];
engine
.sync_firewall(&[fw1], true, &subnets)
.await
.expect("sync firewall");
let ruleset = engine
.get_active_nftables_ruleset()
.await
.expect("get ruleset");
assert!(ruleset.contains("table inet nx9_wg"));
assert!(ruleset.contains("udp dport 51820 accept"));
assert!(ruleset.contains("ip saddr 10.0.0.0/24 oifname != \"wg*\" masquerade"));
assert!(ruleset.contains("ip6 saddr fd00::/64 oifname != \"wg*\" masquerade"));
// 3. IP Forwarding inspection
let status = engine.get_forwarding_status().await.expect("forwarding");
assert!(status.ipv4_enabled);
}
#[test]
fn test_nftables_builder_ordering_and_rules() {
let now = Utc::now().naive_utc();
let r_prio10 = FirewallRule {
id: Uuid::new_v4(),
name: "Low Priority Accept".to_string(),
interface_id: None,
peer_id: None,
direction: FirewallDirection::In,
action: FirewallAction::Accept,
protocol: FirewallProtocol::Tcp,
source: None,
destination: None,
source_port: None,
destination_port: Some(80),
port_range: None,
priority: 10,
enabled: true,
description: None,
created_at: now,
updated_at: now,
};
let r_prio1 = FirewallRule {
id: Uuid::new_v4(),
name: "High Priority Drop".to_string(),
interface_id: None,
peer_id: None,
direction: FirewallDirection::In,
action: FirewallAction::Drop,
protocol: FirewallProtocol::Tcp,
source: Some("1.2.3.4".to_string()),
destination: None,
source_port: None,
destination_port: Some(80),
port_range: None,
priority: 1,
enabled: true,
description: None,
created_at: now,
updated_at: now,
};
let subnets = vec![IpNet::from_str("10.0.0.0/24").unwrap()];
let ruleset = NftablesRulesetBuilder::build(&[r_prio10, r_prio1], false, &subnets);
// High priority drop (priority 1) must appear before low priority accept (priority 10)
let drop_idx = ruleset.find("1.2.3.4").expect("drop rule");
let accept_idx = ruleset.find("dport 80 accept").expect("accept rule");
assert!(
drop_idx < accept_idx,
"Higher priority rule (1) must appear before lower priority rule (10)"
);
}