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,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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user