feat: complete nx9-wg v0.8.0 platform
This commit is contained in:
1 parent
c75e5c4e71
commit
c8a9b7cde6
52 files changed
+7751
-725
No files matched your search
@@ -16,5 +16,11 @@ serde_json.workspace = true
|
||||
ipnet.workspace = true
|
||||
async-trait = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = { workspace = true }
|
||||
netlink-packet-core = { workspace = true }
|
||||
netlink-packet-route = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -28,6 +28,11 @@ pub trait NetworkEngine: Send + Sync {
|
||||
|
||||
/// Get current active generated nftables ruleset.
|
||||
async fn get_active_nftables_ruleset(&self) -> Result<String>;
|
||||
|
||||
/// Check if desired routes have drift against live/active state.
|
||||
async fn has_route_drift(&self, _routes: &[Route]) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory simulated network engine for tests and non-root execution.
|
||||
@@ -88,14 +93,91 @@ impl NetworkEngine for SimulatedNetworkEngine {
|
||||
let active = self.active_ruleset.read().await;
|
||||
Ok(active.clone())
|
||||
}
|
||||
|
||||
async fn has_route_drift(&self, routes: &[Route]) -> Result<bool> {
|
||||
let enabled_routes: Vec<Route> = routes.iter().filter(|r| r.enabled).cloned().collect();
|
||||
let active = self.active_routes.read().await;
|
||||
Ok(enabled_routes != *active)
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux Native Network Engine with kernel sysfs / netlink checks and fallback.
|
||||
/// Linux Native Network Engine with RTNETLINK and direct procfs forwarding.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use crate::native_linux::{
|
||||
FirewallDiagnostics, NativeLinuxNetworkEngine, NativeLinuxNftablesEngine,
|
||||
};
|
||||
|
||||
/// Fallback Simulated Network Engine for non-Linux platforms and unit testing.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NativeLinuxNetworkEngine {
|
||||
fallback: SimulatedNetworkEngine,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FirewallDiagnostics {
|
||||
pub table_exists: bool,
|
||||
pub table_name: String,
|
||||
pub family: String,
|
||||
pub chain_count: usize,
|
||||
pub chains: Vec<String>,
|
||||
pub rule_count: usize,
|
||||
pub nat_enabled: bool,
|
||||
pub live_ruleset: Option<String>,
|
||||
pub kernel_status: String,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NativeLinuxNftablesEngine {
|
||||
fallback: SimulatedNetworkEngine,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl NativeLinuxNftablesEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
fallback: SimulatedNetworkEngine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn table_exists(&self) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn get_live_ruleset(&self) -> Result<String> {
|
||||
self.fallback.get_active_nftables_ruleset().await
|
||||
}
|
||||
|
||||
pub async fn apply_ruleset(&self, ruleset: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_table(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn diagnose(
|
||||
&self,
|
||||
_desired_rules: &[FirewallRule],
|
||||
desired_nat: bool,
|
||||
) -> Result<FirewallDiagnostics> {
|
||||
Ok(FirewallDiagnostics {
|
||||
table_exists: false,
|
||||
table_name: "nx9_wg".to_string(),
|
||||
family: "inet".to_string(),
|
||||
chain_count: 0,
|
||||
chains: Vec::new(),
|
||||
rule_count: 0,
|
||||
nat_enabled: desired_nat,
|
||||
live_ruleset: None,
|
||||
kernel_status: "simulated".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl NativeLinuxNetworkEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -104,6 +186,7 @@ impl NativeLinuxNetworkEngine {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[async_trait::async_trait]
|
||||
impl NetworkEngine for NativeLinuxNetworkEngine {
|
||||
async fn sync_routes(&self, routes: &[Route]) -> Result<()> {
|
||||
|
||||
@@ -12,12 +12,42 @@ pub enum NetworkError {
|
||||
#[error("firewall error: {0}")]
|
||||
Firewall(String),
|
||||
|
||||
#[error("invalid firewall rule: {0}")]
|
||||
FirewallRuleInvalid(String),
|
||||
|
||||
#[error("firewall ownership violation: {0}")]
|
||||
FirewallOwnershipViolation(String),
|
||||
|
||||
#[error("invalid NAT configuration: {0}")]
|
||||
NatConfigurationInvalid(String),
|
||||
|
||||
#[error("nftables error: {0}")]
|
||||
Nftables(String),
|
||||
|
||||
#[error("forwarding error: {0}")]
|
||||
Forwarding(String),
|
||||
|
||||
#[error("interface '{0}' not found")]
|
||||
InterfaceNotFound(String),
|
||||
|
||||
#[error("address '{0}' not found")]
|
||||
AddressNotFound(String),
|
||||
|
||||
#[error("route '{0}' not found")]
|
||||
RouteNotFound(String),
|
||||
|
||||
#[error("invalid address: {0}")]
|
||||
InvalidAddress(String),
|
||||
|
||||
#[error("invalid route: {0}")]
|
||||
InvalidRoute(String),
|
||||
|
||||
#[error("unsupported operation: {0}")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("netlink error: {0}")]
|
||||
Netlink(String),
|
||||
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod forwarding;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod native_linux;
|
||||
pub mod nftables;
|
||||
|
||||
pub use engine::{NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine};
|
||||
|
||||
@@ -0,0 +1,984 @@
|
||||
//! Native Linux Netlink and kernel networking execution plane.
|
||||
//!
|
||||
//! Provides genuine Linux kernel networking operations through RTNETLINK:
|
||||
//! - Interface lifecycle (list, get, up, down)
|
||||
//! - IPv4 & IPv6 Address management (list, add, delete)
|
||||
//! - IPv4 & IPv6 Route management (list, add, delete, deterministic reconciliation)
|
||||
//! - IP forwarding status and mutation via procfs
|
||||
//! - Dedicated nftables ruleset generation and caching
|
||||
//!
|
||||
//! Zero subprocesses or shell commands are invoked.
|
||||
|
||||
use crate::engine::NetworkEngine;
|
||||
use crate::error::{NetworkError, Result};
|
||||
use crate::forwarding::IpForwardingStatus;
|
||||
use crate::nftables::NftablesRulesetBuilder;
|
||||
use futures::stream::TryStreamExt;
|
||||
use ipnet::IpNet;
|
||||
use netlink_packet_route::AddressFamily;
|
||||
use netlink_packet_route::address::AddressAttribute;
|
||||
use netlink_packet_route::link::{LinkAttribute, LinkFlags};
|
||||
use netlink_packet_route::route::{RouteAddress, RouteAttribute, RouteMessage};
|
||||
use nx9_wg_core::types::firewall::FirewallRule;
|
||||
use nx9_wg_core::types::network::Route;
|
||||
use rtnetlink::{Handle, LinkUnspec, RouteMessageBuilder, new_connection};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Summary information for a network interface discovered via RTNETLINK.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceInfo {
|
||||
pub index: u32,
|
||||
pub name: String,
|
||||
pub is_up: bool,
|
||||
pub mtu: Option<u32>,
|
||||
pub oper_state: Option<String>,
|
||||
}
|
||||
|
||||
/// Address record attached to an interface discovered via RTNETLINK.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AddressInfo {
|
||||
pub index: u32,
|
||||
pub address: IpAddr,
|
||||
pub prefix_len: u8,
|
||||
}
|
||||
|
||||
/// Routing table entry discovered via RTNETLINK.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RouteInfo {
|
||||
pub destination: IpNet,
|
||||
pub gateway: Option<IpAddr>,
|
||||
pub oif: Option<u32>,
|
||||
pub table: u32,
|
||||
pub metric: Option<u32>,
|
||||
}
|
||||
|
||||
/// Connect to RTNETLINK and spawn background event loop.
|
||||
fn connect_rtnetlink() -> Result<(Handle, tokio::task::JoinHandle<()>)> {
|
||||
let (conn, handle, _) = new_connection().map_err(|e| {
|
||||
NetworkError::Netlink(format!("Failed to establish RTNETLINK connection: {e}"))
|
||||
})?;
|
||||
let join_handle = tokio::spawn(conn);
|
||||
Ok((handle, join_handle))
|
||||
}
|
||||
|
||||
/// List all network interfaces using RTNETLINK link dump.
|
||||
pub async fn list_interfaces() -> Result<Vec<InterfaceInfo>> {
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let mut links = handle.link().get().execute();
|
||||
let mut results = Vec::new();
|
||||
|
||||
while let Some(msg) = links
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| NetworkError::Netlink(format!("RTNETLINK link dump failed: {e}")))?
|
||||
{
|
||||
let index = msg.header.index;
|
||||
let is_up = msg.header.flags.contains(LinkFlags::Up);
|
||||
let mut name = String::new();
|
||||
let mut mtu = None;
|
||||
let mut oper_state = None;
|
||||
|
||||
for attr in msg.attributes {
|
||||
match attr {
|
||||
LinkAttribute::IfName(n) => name = n,
|
||||
LinkAttribute::Mtu(m) => mtu = Some(m),
|
||||
LinkAttribute::OperState(s) => oper_state = Some(format!("{s:?}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !name.is_empty() {
|
||||
results.push(InterfaceInfo {
|
||||
index,
|
||||
name,
|
||||
is_up,
|
||||
mtu,
|
||||
oper_state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Query a single interface by name using RTNETLINK.
|
||||
pub async fn get_interface(name: &str) -> Result<InterfaceInfo> {
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
|
||||
while let Some(msg) = links.try_next().await.map_err(|e| {
|
||||
NetworkError::Netlink(format!("RTNETLINK get link failed for '{name}': {e}"))
|
||||
})? {
|
||||
let index = msg.header.index;
|
||||
let is_up = msg.header.flags.contains(LinkFlags::Up);
|
||||
let mut if_name = String::new();
|
||||
let mut mtu = None;
|
||||
let mut oper_state = None;
|
||||
|
||||
for attr in msg.attributes {
|
||||
match attr {
|
||||
LinkAttribute::IfName(n) => if_name = n,
|
||||
LinkAttribute::Mtu(m) => mtu = Some(m),
|
||||
LinkAttribute::OperState(s) => oper_state = Some(format!("{s:?}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if if_name == name {
|
||||
return Ok(InterfaceInfo {
|
||||
index,
|
||||
name: if_name,
|
||||
is_up,
|
||||
mtu,
|
||||
oper_state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(NetworkError::InterfaceNotFound(name.to_string()))
|
||||
}
|
||||
|
||||
/// Bring an interface UP using RTNETLINK.
|
||||
pub async fn interface_up(name: &str) -> Result<()> {
|
||||
let iface = get_interface(name).await?;
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let msg = LinkUnspec::new_with_index(iface.index).up().build();
|
||||
handle.link().change(msg).execute().await.map_err(|e| {
|
||||
NetworkError::Netlink(format!("Failed to bring interface '{name}' UP: {e}"))
|
||||
})?;
|
||||
tracing::info!(interface = name, "Interface brought UP via RTNETLINK");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bring an interface DOWN using RTNETLINK.
|
||||
pub async fn interface_down(name: &str) -> Result<()> {
|
||||
let iface = get_interface(name).await?;
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let msg = LinkUnspec::new_with_index(iface.index).down().build();
|
||||
handle.link().change(msg).execute().await.map_err(|e| {
|
||||
NetworkError::Netlink(format!("Failed to bring interface '{name}' DOWN: {e}"))
|
||||
})?;
|
||||
tracing::info!(interface = name, "Interface brought DOWN via RTNETLINK");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all IP addresses on all interfaces using RTNETLINK.
|
||||
pub async fn list_addresses() -> Result<Vec<AddressInfo>> {
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let mut addrs = handle.address().get().execute();
|
||||
let mut results = Vec::new();
|
||||
|
||||
while let Some(msg) = addrs
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| NetworkError::Netlink(format!("RTNETLINK address dump failed: {e}")))?
|
||||
{
|
||||
let index = msg.header.index;
|
||||
let prefix_len = msg.header.prefix_len;
|
||||
|
||||
for attr in msg.attributes {
|
||||
if let AddressAttribute::Address(ip) = attr {
|
||||
results.push(AddressInfo {
|
||||
index,
|
||||
address: ip,
|
||||
prefix_len,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// List IP addresses associated with a specific interface index.
|
||||
pub async fn get_addresses_for_interface(index: u32) -> Result<Vec<AddressInfo>> {
|
||||
let all = list_addresses().await?;
|
||||
Ok(all.into_iter().filter(|a| a.index == index).collect())
|
||||
}
|
||||
|
||||
/// Add an IP address to an interface using RTNETLINK.
|
||||
pub async fn add_address(interface_name: &str, ip: IpNet) -> Result<()> {
|
||||
let iface = get_interface(interface_name).await?;
|
||||
let existing = get_addresses_for_interface(iface.index).await?;
|
||||
|
||||
// Idempotency: skip if exact address/prefix already exists on interface
|
||||
if existing
|
||||
.iter()
|
||||
.any(|a| a.address == ip.addr() && a.prefix_len == ip.prefix_len())
|
||||
{
|
||||
tracing::debug!(
|
||||
interface = interface_name,
|
||||
address = %ip,
|
||||
"Address already assigned to interface; skipping addition"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
handle
|
||||
.address()
|
||||
.add(iface.index, ip.addr(), ip.prefix_len())
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
NetworkError::Netlink(format!(
|
||||
"Failed to add address '{ip}' to interface '{interface_name}': {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::info!(interface = interface_name, address = %ip, "Address added via RTNETLINK");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an IP address from an interface using RTNETLINK.
|
||||
pub async fn delete_address(interface_name: &str, ip: IpNet) -> Result<()> {
|
||||
let iface = get_interface(interface_name).await?;
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let mut addrs = handle.address().get().execute();
|
||||
|
||||
while let Some(msg) = addrs
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| NetworkError::Netlink(format!("RTNETLINK address query failed: {e}")))?
|
||||
{
|
||||
if msg.header.index != iface.index || msg.header.prefix_len != ip.prefix_len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_matching_addr = msg.attributes.iter().any(|attr| match attr {
|
||||
AddressAttribute::Address(a) | AddressAttribute::Local(a) => *a == ip.addr(),
|
||||
_ => false,
|
||||
});
|
||||
|
||||
if has_matching_addr {
|
||||
handle.address().del(msg).execute().await.map_err(|e| {
|
||||
NetworkError::Netlink(format!(
|
||||
"Failed to delete address '{ip}' from '{interface_name}': {e}"
|
||||
))
|
||||
})?;
|
||||
tracing::info!(interface = interface_name, address = %ip, "Address deleted via RTNETLINK");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all IPv4 and IPv6 routes using RTNETLINK route dump.
|
||||
pub async fn list_routes() -> Result<Vec<RouteInfo>> {
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 1. IPv4 Routes
|
||||
let mut v4_req = RouteMessage::default();
|
||||
v4_req.header.address_family = AddressFamily::Inet;
|
||||
let mut v4_stream = handle.route().get(v4_req).execute();
|
||||
while let Some(msg) = v4_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| NetworkError::Netlink(format!("RTNETLINK IPv4 route dump failed: {e}")))?
|
||||
{
|
||||
if let Some(r) = parse_route_message(&msg, AddressFamily::Inet) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. IPv6 Routes
|
||||
let mut v6_req = RouteMessage::default();
|
||||
v6_req.header.address_family = AddressFamily::Inet6;
|
||||
let mut v6_stream = handle.route().get(v6_req).execute();
|
||||
while let Some(msg) = v6_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| NetworkError::Netlink(format!("RTNETLINK IPv6 route dump failed: {e}")))?
|
||||
{
|
||||
if let Some(r) = parse_route_message(&msg, AddressFamily::Inet6) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Helper to parse a raw RTNETLINK `RouteMessage` into domain `RouteInfo`.
|
||||
fn parse_route_message(msg: &RouteMessage, family: AddressFamily) -> Option<RouteInfo> {
|
||||
let prefix_len = msg.header.destination_prefix_length;
|
||||
let mut dest_ip = match family {
|
||||
AddressFamily::Inet => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
AddressFamily::Inet6 => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
_ => return None,
|
||||
};
|
||||
let mut gateway = None;
|
||||
let mut oif = None;
|
||||
let mut metric = None;
|
||||
let mut table = msg.header.table as u32;
|
||||
|
||||
for attr in &msg.attributes {
|
||||
match attr {
|
||||
RouteAttribute::Destination(RouteAddress::Inet(v4)) => dest_ip = IpAddr::V4(*v4),
|
||||
RouteAttribute::Destination(RouteAddress::Inet6(v6)) => dest_ip = IpAddr::V6(*v6),
|
||||
RouteAttribute::Gateway(RouteAddress::Inet(v4)) => gateway = Some(IpAddr::V4(*v4)),
|
||||
RouteAttribute::Gateway(RouteAddress::Inet6(v6)) => gateway = Some(IpAddr::V6(*v6)),
|
||||
RouteAttribute::Oif(idx) => oif = Some(*idx),
|
||||
RouteAttribute::Priority(p) => metric = Some(*p),
|
||||
RouteAttribute::Table(t) => table = *t,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let destination = match IpNet::new(dest_ip, prefix_len) {
|
||||
Ok(net) => net,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
Some(RouteInfo {
|
||||
destination,
|
||||
gateway,
|
||||
oif,
|
||||
table,
|
||||
metric,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add an IPv4 or IPv6 route to the kernel routing table via RTNETLINK.
|
||||
pub async fn add_route(route: &Route) -> Result<()> {
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let oif_index = if let Some(ref ifname) = route.interface_name {
|
||||
match get_interface(ifname).await {
|
||||
Ok(info) => Some(info.index),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
interface = ifname,
|
||||
"Could not resolve interface for route: {e}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match route.destination {
|
||||
IpNet::V4(v4) => {
|
||||
let mut builder = RouteMessageBuilder::<Ipv4Addr>::new();
|
||||
builder = builder.destination_prefix(v4.addr(), v4.prefix_len());
|
||||
|
||||
if let Some(IpAddr::V4(gw)) = route.gateway {
|
||||
builder = builder.gateway(gw);
|
||||
}
|
||||
if let Some(idx) = oif_index {
|
||||
builder = builder.output_interface(idx);
|
||||
}
|
||||
if let Some(metric) = route.metric {
|
||||
builder = builder.priority(metric);
|
||||
}
|
||||
|
||||
let msg = builder.build();
|
||||
if let Err(e) = handle.route().add(msg).execute().await {
|
||||
// If route already exists (EEXIST), handle idempotently
|
||||
let err_str = e.to_string();
|
||||
if !err_str.contains("File exists") && !err_str.contains("17") {
|
||||
return Err(NetworkError::Netlink(format!(
|
||||
"Failed to add IPv4 route '{}': {e}",
|
||||
route.destination
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
IpNet::V6(v6) => {
|
||||
let mut builder = RouteMessageBuilder::<Ipv6Addr>::new();
|
||||
builder = builder.destination_prefix(v6.addr(), v6.prefix_len());
|
||||
|
||||
if let Some(IpAddr::V6(gw)) = route.gateway {
|
||||
builder = builder.gateway(gw);
|
||||
}
|
||||
if let Some(idx) = oif_index {
|
||||
builder = builder.output_interface(idx);
|
||||
}
|
||||
if let Some(metric) = route.metric {
|
||||
builder = builder.priority(metric);
|
||||
}
|
||||
|
||||
let msg = builder.build();
|
||||
if let Err(e) = handle.route().add(msg).execute().await {
|
||||
let err_str = e.to_string();
|
||||
if !err_str.contains("File exists") && !err_str.contains("17") {
|
||||
return Err(NetworkError::Netlink(format!(
|
||||
"Failed to add IPv6 route '{}': {e}",
|
||||
route.destination
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
destination = %route.destination,
|
||||
gateway = ?route.gateway,
|
||||
interface = ?route.interface_name,
|
||||
"Route added via RTNETLINK"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a route from the kernel routing table via RTNETLINK.
|
||||
pub async fn delete_route(route: &Route) -> Result<()> {
|
||||
// Safety check: Never delete default routes unless explicitly verified as an nx9 managed route
|
||||
let is_default =
|
||||
route.destination.addr().is_unspecified() && route.destination.prefix_len() == 0;
|
||||
if is_default && route.interface_name.is_none() {
|
||||
return Err(NetworkError::Routing(
|
||||
"Refusing to delete global default route without specific interface binding"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (handle, _join) = connect_rtnetlink()?;
|
||||
let oif_index = if let Some(ref ifname) = route.interface_name {
|
||||
get_interface(ifname).await.ok().map(|i| i.index)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut get_msg = RouteMessage::default();
|
||||
get_msg.header.address_family = match route.destination {
|
||||
IpNet::V4(_) => AddressFamily::Inet,
|
||||
IpNet::V6(_) => AddressFamily::Inet6,
|
||||
};
|
||||
|
||||
let mut stream = handle.route().get(get_msg).execute();
|
||||
while let Some(msg) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| NetworkError::Netlink(format!("RTNETLINK route query failed: {e}")))?
|
||||
{
|
||||
if msg.header.destination_prefix_length != route.destination.prefix_len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut dest_match = false;
|
||||
let mut gw_match = route.gateway.is_none();
|
||||
let mut oif_match = oif_index.is_none();
|
||||
|
||||
for attr in &msg.attributes {
|
||||
match attr {
|
||||
RouteAttribute::Destination(RouteAddress::Inet(v4))
|
||||
if IpAddr::V4(*v4) == route.destination.addr() =>
|
||||
{
|
||||
dest_match = true;
|
||||
}
|
||||
RouteAttribute::Destination(RouteAddress::Inet6(v6))
|
||||
if IpAddr::V6(*v6) == route.destination.addr() =>
|
||||
{
|
||||
dest_match = true;
|
||||
}
|
||||
RouteAttribute::Gateway(RouteAddress::Inet(v4))
|
||||
if Some(IpAddr::V4(*v4)) == route.gateway =>
|
||||
{
|
||||
gw_match = true;
|
||||
}
|
||||
RouteAttribute::Gateway(RouteAddress::Inet6(v6))
|
||||
if Some(IpAddr::V6(*v6)) == route.gateway =>
|
||||
{
|
||||
gw_match = true;
|
||||
}
|
||||
RouteAttribute::Oif(idx) if Some(*idx) == oif_index => {
|
||||
oif_match = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// For default prefix /0, dest_match is true if destination is unspecified
|
||||
if route.destination.prefix_len() == 0 {
|
||||
dest_match = true;
|
||||
}
|
||||
|
||||
if dest_match && gw_match && oif_match {
|
||||
handle.route().del(msg).execute().await.map_err(|e| {
|
||||
NetworkError::Netlink(format!(
|
||||
"Failed to delete route '{}': {e}",
|
||||
route.destination
|
||||
))
|
||||
})?;
|
||||
tracing::info!(destination = %route.destination, "Route deleted via RTNETLINK");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Real Native Linux Network Engine communicating directly with kernel RTNETLINK.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NativeLinuxNetworkEngine {
|
||||
active_ruleset: Arc<RwLock<String>>,
|
||||
}
|
||||
|
||||
impl NativeLinuxNetworkEngine {
|
||||
/// Create a new NativeLinuxNetworkEngine instance.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active_ruleset: Arc::new(RwLock::new(String::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for inspecting network interfaces.
|
||||
pub async fn list_interfaces(&self) -> Result<Vec<InterfaceInfo>> {
|
||||
list_interfaces().await
|
||||
}
|
||||
|
||||
/// Helper for inspecting a single interface.
|
||||
pub async fn get_interface(&self, name: &str) -> Result<InterfaceInfo> {
|
||||
get_interface(name).await
|
||||
}
|
||||
|
||||
/// Helper for bringing an interface UP.
|
||||
pub async fn interface_up(&self, name: &str) -> Result<()> {
|
||||
interface_up(name).await
|
||||
}
|
||||
|
||||
/// Helper for bringing an interface DOWN.
|
||||
pub async fn interface_down(&self, name: &str) -> Result<()> {
|
||||
interface_down(name).await
|
||||
}
|
||||
|
||||
/// Helper for listing IP addresses.
|
||||
pub async fn list_addresses(&self) -> Result<Vec<AddressInfo>> {
|
||||
list_addresses().await
|
||||
}
|
||||
|
||||
/// Helper for adding an IP address.
|
||||
pub async fn add_address(&self, interface_name: &str, ip: IpNet) -> Result<()> {
|
||||
add_address(interface_name, ip).await
|
||||
}
|
||||
|
||||
/// Helper for deleting an IP address.
|
||||
pub async fn delete_address(&self, interface_name: &str, ip: IpNet) -> Result<()> {
|
||||
delete_address(interface_name, ip).await
|
||||
}
|
||||
|
||||
/// Helper for listing live routes.
|
||||
pub async fn list_routes(&self) -> Result<Vec<RouteInfo>> {
|
||||
list_routes().await
|
||||
}
|
||||
|
||||
/// Helper for setting IP forwarding.
|
||||
pub async fn set_forwarding_status(&self, status: IpForwardingStatus) -> Result<()> {
|
||||
IpForwardingStatus::set_ipv4(status.ipv4_enabled)?;
|
||||
IpForwardingStatus::set_ipv6(status.ipv6_enabled)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Native Linux nftables Execution Engine (In-Process Netlink via libnftables)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[link(name = "nftables")]
|
||||
unsafe extern "C" {
|
||||
fn nft_ctx_new(flags: u32) -> *mut std::ffi::c_void;
|
||||
fn nft_ctx_free(ctx: *mut std::ffi::c_void);
|
||||
fn nft_ctx_buffer_output(ctx: *mut std::ffi::c_void) -> std::ffi::c_int;
|
||||
fn nft_ctx_buffer_error(ctx: *mut std::ffi::c_void) -> std::ffi::c_int;
|
||||
fn nft_ctx_get_output_buffer(ctx: *mut std::ffi::c_void) -> *const std::ffi::c_char;
|
||||
fn nft_ctx_get_error_buffer(ctx: *mut std::ffi::c_void) -> *const std::ffi::c_char;
|
||||
fn nft_run_cmd_from_buffer(
|
||||
ctx: *mut std::ffi::c_void,
|
||||
buf: *const std::ffi::c_char,
|
||||
) -> std::ffi::c_int;
|
||||
}
|
||||
|
||||
/// Safe RAII wrapper around `struct nft_ctx*`.
|
||||
pub struct NftContext {
|
||||
raw: *mut std::ffi::c_void,
|
||||
}
|
||||
|
||||
unsafe impl Send for NftContext {}
|
||||
unsafe impl Sync for NftContext {}
|
||||
|
||||
impl NftContext {
|
||||
/// Create a new in-process nftables Netlink context with buffered I/O.
|
||||
pub fn new() -> Result<Self> {
|
||||
let raw = unsafe { nft_ctx_new(0) };
|
||||
if raw.is_null() {
|
||||
return Err(NetworkError::Nftables(
|
||||
"Failed to allocate nftables context".to_string(),
|
||||
));
|
||||
}
|
||||
unsafe {
|
||||
nft_ctx_buffer_output(raw);
|
||||
nft_ctx_buffer_error(raw);
|
||||
}
|
||||
Ok(Self { raw })
|
||||
}
|
||||
|
||||
/// Execute a command buffer directly against the kernel via Netlink.
|
||||
pub fn run_cmd(&mut self, cmd: &str) -> std::result::Result<String, (i32, String)> {
|
||||
let c_cmd = std::ffi::CString::new(cmd)
|
||||
.map_err(|e| (-1, format!("CString conversion failed: {e}")))?;
|
||||
let rc = unsafe { nft_run_cmd_from_buffer(self.raw, c_cmd.as_ptr()) };
|
||||
let output = unsafe {
|
||||
let ptr = nft_ctx_get_output_buffer(self.raw);
|
||||
if ptr.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
|
||||
}
|
||||
};
|
||||
let error = unsafe {
|
||||
let ptr = nft_ctx_get_error_buffer(self.raw);
|
||||
if ptr.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
|
||||
}
|
||||
};
|
||||
|
||||
if rc == 0 {
|
||||
Ok(output)
|
||||
} else {
|
||||
Err((rc, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NftContext {
|
||||
fn drop(&mut self) {
|
||||
if !self.raw.is_null() {
|
||||
unsafe { nft_ctx_free(self.raw) };
|
||||
self.raw = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured diagnostic telemetry for Linux nftables kernel state.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FirewallDiagnostics {
|
||||
pub table_exists: bool,
|
||||
pub table_name: String,
|
||||
pub family: String,
|
||||
pub chain_count: usize,
|
||||
pub chains: Vec<String>,
|
||||
pub rule_count: usize,
|
||||
pub nat_enabled: bool,
|
||||
pub live_ruleset: Option<String>,
|
||||
pub kernel_status: String,
|
||||
}
|
||||
|
||||
/// Controller for the dedicated `nx9_wg` nftables table and chains in the Linux kernel.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NativeLinuxNftablesEngine;
|
||||
|
||||
impl NativeLinuxNftablesEngine {
|
||||
/// Create a new native nftables engine instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Check if the dedicated `table inet nx9_wg` exists in the kernel.
|
||||
pub async fn table_exists(&self) -> Result<bool> {
|
||||
let mut ctx = NftContext::new()?;
|
||||
match ctx.run_cmd("list table inet nx9_wg") {
|
||||
Ok(_) => Ok(true),
|
||||
Err((_, err)) => {
|
||||
if err.contains("No such file or directory") || err.contains("does not exist") {
|
||||
Ok(false)
|
||||
} else if err.contains("Permission denied")
|
||||
|| err.contains("Operation not permitted")
|
||||
{
|
||||
Err(NetworkError::PermissionDenied(err))
|
||||
} else {
|
||||
Err(NetworkError::Nftables(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the active `table inet nx9_wg` ruleset directly from the kernel.
|
||||
pub async fn get_live_ruleset(&self) -> Result<String> {
|
||||
let mut ctx = NftContext::new()?;
|
||||
match ctx.run_cmd("list table inet nx9_wg") {
|
||||
Ok(output) => Ok(output),
|
||||
Err((_, err)) => {
|
||||
if err.contains("No such file or directory") || err.contains("does not exist") {
|
||||
Ok(String::new())
|
||||
} else if err.contains("Permission denied")
|
||||
|| err.contains("Operation not permitted")
|
||||
{
|
||||
Err(NetworkError::PermissionDenied(err))
|
||||
} else {
|
||||
Err(NetworkError::Nftables(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an atomic ruleset update to `table inet nx9_wg`.
|
||||
///
|
||||
/// # Safety and Ownership Invariant
|
||||
/// Verifies that the ruleset ONLY modifies `table inet nx9_wg`.
|
||||
/// Never flushes or deletes tables outside `nx9_wg`.
|
||||
pub async fn apply_ruleset(&self, ruleset: &str) -> Result<()> {
|
||||
// Enforce ownership: reject any ruleset targeting outside table inet nx9_wg
|
||||
for line in ruleset.lines() {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.starts_with("table ")
|
||||
|| trimmed.starts_with("flush table ")
|
||||
|| trimmed.starts_with("delete table "))
|
||||
&& !trimmed.contains("table inet nx9_wg")
|
||||
{
|
||||
return Err(NetworkError::FirewallOwnershipViolation(format!(
|
||||
"Refusing to execute command outside 'table inet nx9_wg': {trimmed}"
|
||||
)));
|
||||
}
|
||||
if trimmed == "flush ruleset" {
|
||||
return Err(NetworkError::FirewallOwnershipViolation(
|
||||
"Refusing to flush global nftables ruleset".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Construct atomic table replacement transaction
|
||||
let atomic_tx = format!("table inet nx9_wg\ndelete table inet nx9_wg\n{ruleset}");
|
||||
|
||||
let mut ctx = NftContext::new()?;
|
||||
match ctx.run_cmd(&atomic_tx) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Atomic nftables ruleset applied for 'table inet nx9_wg'");
|
||||
Ok(())
|
||||
}
|
||||
Err((rc, err)) => {
|
||||
if err.contains("Permission denied") || err.contains("Operation not permitted") {
|
||||
Err(NetworkError::PermissionDenied(format!(
|
||||
"Insufficient privileges to modify kernel nftables (requires CAP_NET_ADMIN): {err}"
|
||||
)))
|
||||
} else {
|
||||
Err(NetworkError::Nftables(format!(
|
||||
"Failed to apply atomic nftables transaction (exit code {rc}): {err}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the dedicated `table inet nx9_wg` from the kernel.
|
||||
pub async fn delete_table(&self) -> Result<()> {
|
||||
let mut ctx = NftContext::new()?;
|
||||
match ctx.run_cmd("delete table inet nx9_wg") {
|
||||
Ok(_) => {
|
||||
tracing::info!("Deleted 'table inet nx9_wg' from kernel");
|
||||
Ok(())
|
||||
}
|
||||
Err((_, err)) => {
|
||||
if err.contains("No such file or directory") || err.contains("does not exist") {
|
||||
Ok(())
|
||||
} else if err.contains("Permission denied")
|
||||
|| err.contains("Operation not permitted")
|
||||
{
|
||||
Err(NetworkError::PermissionDenied(err))
|
||||
} else {
|
||||
Err(NetworkError::Nftables(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce read-only diagnostic telemetry for firewall and NAT state.
|
||||
pub async fn diagnose(
|
||||
&self,
|
||||
_desired_rules: &[FirewallRule],
|
||||
desired_nat: bool,
|
||||
) -> Result<FirewallDiagnostics> {
|
||||
let mut ctx = NftContext::new()?;
|
||||
match ctx.run_cmd("list table inet nx9_wg") {
|
||||
Ok(live) => {
|
||||
let chain_input = live.contains("chain input");
|
||||
let chain_forward = live.contains("chain forward");
|
||||
let chain_postrouting = live.contains("chain postrouting");
|
||||
let mut chains = Vec::new();
|
||||
if chain_input {
|
||||
chains.push("input".to_string());
|
||||
}
|
||||
if chain_forward {
|
||||
chains.push("forward".to_string());
|
||||
}
|
||||
if chain_postrouting {
|
||||
chains.push("postrouting".to_string());
|
||||
}
|
||||
|
||||
let rule_count = live
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim();
|
||||
!t.is_empty()
|
||||
&& !t.starts_with('#')
|
||||
&& !t.starts_with("table ")
|
||||
&& !t.starts_with("chain ")
|
||||
&& !t.starts_with('}')
|
||||
&& !t.starts_with("type ")
|
||||
})
|
||||
.count();
|
||||
|
||||
let nat_enabled = live.contains("masquerade");
|
||||
|
||||
Ok(FirewallDiagnostics {
|
||||
table_exists: true,
|
||||
table_name: "nx9_wg".to_string(),
|
||||
family: "inet".to_string(),
|
||||
chain_count: chains.len(),
|
||||
chains,
|
||||
rule_count,
|
||||
nat_enabled,
|
||||
live_ruleset: Some(live),
|
||||
kernel_status: "active".to_string(),
|
||||
})
|
||||
}
|
||||
Err((_, err)) => {
|
||||
let exists =
|
||||
!err.contains("No such file or directory") && !err.contains("does not exist");
|
||||
Ok(FirewallDiagnostics {
|
||||
table_exists: exists,
|
||||
table_name: "nx9_wg".to_string(),
|
||||
family: "inet".to_string(),
|
||||
chain_count: 0,
|
||||
chains: Vec::new(),
|
||||
rule_count: 0,
|
||||
nat_enabled: desired_nat,
|
||||
live_ruleset: None,
|
||||
kernel_status: if exists { err } else { "not_found".to_string() },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NetworkEngine Trait Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NetworkEngine for NativeLinuxNetworkEngine {
|
||||
/// Deterministically reconcile kernel routing table entries with desired routes.
|
||||
///
|
||||
/// Preserves unmanaged system routes and default gateways while synchronizing
|
||||
/// nx9-wg desired routes.
|
||||
async fn sync_routes(&self, routes: &[Route]) -> Result<()> {
|
||||
let live_routes = list_routes().await.unwrap_or_default();
|
||||
let enabled_routes: Vec<&Route> = routes.iter().filter(|r| r.enabled).collect();
|
||||
let disabled_routes: Vec<&Route> = routes.iter().filter(|r| !r.enabled).collect();
|
||||
|
||||
// 1. Add or converge missing/changed enabled routes
|
||||
for desired in &enabled_routes {
|
||||
let matches_live = live_routes.iter().any(|live| {
|
||||
live.destination == desired.destination
|
||||
&& (desired.gateway.is_none() || live.gateway == desired.gateway)
|
||||
});
|
||||
|
||||
if !matches_live && let Err(e) = add_route(desired).await {
|
||||
tracing::warn!(error = %e, route = %desired.destination, "Kernel route addition skipped (unprivileged or missing CAP_NET_ADMIN)");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove explicitly disabled routes that are present in the kernel
|
||||
for disabled in &disabled_routes {
|
||||
let matches_live = live_routes.iter().any(|live| {
|
||||
live.destination == disabled.destination
|
||||
&& (disabled.gateway.is_none() || live.gateway == disabled.gateway)
|
||||
});
|
||||
|
||||
if matches_live {
|
||||
let _ = delete_route(disabled).await;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
active = enabled_routes.len(),
|
||||
disabled = disabled_routes.len(),
|
||||
"Native Linux kernel routes synchronized via RTNETLINK"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronize the dedicated `table inet nx9_wg` nftables ruleset.
|
||||
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.clone();
|
||||
}
|
||||
|
||||
let nft = NativeLinuxNftablesEngine::new();
|
||||
match nft.apply_ruleset(&ruleset).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Native Linux nftables 'table inet nx9_wg' synchronized successfully via Netlink"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Kernel nftables application skipped (unprivileged or non-root context)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect kernel IP packet forwarding status via /proc/sys/net.
|
||||
async fn get_forwarding_status(&self) -> Result<IpForwardingStatus> {
|
||||
IpForwardingStatus::detect()
|
||||
}
|
||||
|
||||
/// Get current active generated or live nftables ruleset.
|
||||
async fn get_active_nftables_ruleset(&self) -> Result<String> {
|
||||
let nft = NativeLinuxNftablesEngine::new();
|
||||
match nft.get_live_ruleset().await {
|
||||
Ok(live) if !live.trim().is_empty() => Ok(live),
|
||||
_ => {
|
||||
let active = self.active_ruleset.read().await;
|
||||
if active.is_empty() {
|
||||
Ok(NftablesRulesetBuilder::build(&[], true, &[]))
|
||||
} else {
|
||||
Ok(active.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_route_drift(&self, routes: &[Route]) -> Result<bool> {
|
||||
let live_routes = list_routes().await.unwrap_or_default();
|
||||
let enabled_routes: Vec<&Route> = routes.iter().filter(|r| r.enabled).collect();
|
||||
let disabled_routes: Vec<&Route> = routes.iter().filter(|r| !r.enabled).collect();
|
||||
|
||||
// 1. Any enabled route missing from live routes?
|
||||
for desired in &enabled_routes {
|
||||
let found = live_routes.iter().any(|live| {
|
||||
live.destination == desired.destination
|
||||
&& (desired.gateway.is_none() || live.gateway == desired.gateway)
|
||||
});
|
||||
if !found {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Any disabled route still present in live routes?
|
||||
for disabled in &disabled_routes {
|
||||
let found = live_routes.iter().any(|live| {
|
||||
live.destination == disabled.destination
|
||||
&& (disabled.gateway.is_none() || live.gateway == disabled.gateway)
|
||||
});
|
||||
if found {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,11 @@ impl NftablesRulesetBuilder {
|
||||
// Build Postrouting / NAT Masquerade rules
|
||||
let mut nat_rules = Vec::new();
|
||||
if enable_nat {
|
||||
for subnet in wg_subnets {
|
||||
let mut unique_subnets = wg_subnets.to_vec();
|
||||
unique_subnets.sort();
|
||||
unique_subnets.dedup();
|
||||
|
||||
for subnet in unique_subnets {
|
||||
match subnet {
|
||||
IpNet::V4(v4) => {
|
||||
nat_rules.push(format!(
|
||||
@@ -280,4 +284,51 @@ mod tests {
|
||||
"meta l4proto { tcp, udp } ip saddr 10.0.0.5 th dport { 53, 80, 443 } accept"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nat_masquerade_empty_subnets() {
|
||||
let ruleset = NftablesRulesetBuilder::build(&[], true, &[]);
|
||||
assert!(
|
||||
!ruleset.contains("masquerade"),
|
||||
"Empty subnet list must not generate masquerade rules"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nat_masquerade_disabled() {
|
||||
let subnets = vec![
|
||||
"10.100.0.0/24".parse().unwrap(),
|
||||
"fd00::/64".parse().unwrap(),
|
||||
];
|
||||
let ruleset = NftablesRulesetBuilder::build(&[], false, &subnets);
|
||||
assert!(
|
||||
!ruleset.contains("masquerade"),
|
||||
"Disabled NAT must not generate masquerade rules"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nat_masquerade_multiple_subnets_and_deduplication() {
|
||||
let subnets = vec![
|
||||
"10.100.0.0/24".parse().unwrap(),
|
||||
"10.200.0.0/24".parse().unwrap(),
|
||||
"10.100.0.0/24".parse().unwrap(), // duplicate
|
||||
"fd00:1::/64".parse().unwrap(),
|
||||
"fd00:2::/64".parse().unwrap(),
|
||||
];
|
||||
let ruleset = NftablesRulesetBuilder::build(&[], true, &subnets);
|
||||
assert!(ruleset.contains("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade"));
|
||||
assert!(ruleset.contains("ip saddr 10.200.0.0/24 oifname != \"wg*\" masquerade"));
|
||||
assert!(ruleset.contains("ip6 saddr fd00:1::/64 oifname != \"wg*\" masquerade"));
|
||||
assert!(ruleset.contains("ip6 saddr fd00:2::/64 oifname != \"wg*\" masquerade"));
|
||||
|
||||
// Verify deduplication: 10.100.0.0/24 appears exactly once in masquerade statements
|
||||
let count = ruleset
|
||||
.matches("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade")
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Duplicate subnet must be deduplicated to exactly one masquerade rule"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Kernel-independent unit and conversion tests for Phase 2 Native Linux Network Engine.
|
||||
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::network::Route;
|
||||
use nx9_wg_network::error::NetworkError;
|
||||
use nx9_wg_network::forwarding::IpForwardingStatus;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_ipv4_route_destination_conversion() {
|
||||
let dest = IpNet::from_str("192.168.10.0/24").expect("valid cidr");
|
||||
assert_eq!(dest.addr(), IpAddr::V4(Ipv4Addr::new(192, 168, 10, 0)));
|
||||
assert_eq!(dest.prefix_len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_route_destination_conversion() {
|
||||
let dest = IpNet::from_str("fd00:abcd::/64").expect("valid ipv6 cidr");
|
||||
assert_eq!(dest.prefix_len(), 64);
|
||||
assert!(dest.addr().is_ipv6());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_gateway_resolution() {
|
||||
let now = Utc::now().naive_utc();
|
||||
let r_with_gw = Route {
|
||||
id: Uuid::new_v4(),
|
||||
network_id: None,
|
||||
interface_id: None,
|
||||
destination: IpNet::from_str("10.100.0.0/16").unwrap(),
|
||||
gateway: Some(IpAddr::from_str("10.0.0.1").unwrap()),
|
||||
interface_name: Some("wg0".to_string()),
|
||||
metric: Some(50),
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
assert!(r_with_gw.gateway.is_some());
|
||||
assert_eq!(
|
||||
r_with_gw.gateway.unwrap(),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
|
||||
);
|
||||
|
||||
let r_no_gw = Route {
|
||||
id: Uuid::new_v4(),
|
||||
network_id: None,
|
||||
interface_id: None,
|
||||
destination: IpNet::from_str("10.200.0.0/16").unwrap(),
|
||||
gateway: None,
|
||||
interface_name: Some("wg0".to_string()),
|
||||
metric: None,
|
||||
enabled: true,
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
assert!(r_no_gw.gateway.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_route_safety_invariants() {
|
||||
let v4_default = IpNet::from_str("0.0.0.0/0").unwrap();
|
||||
assert!(v4_default.addr().is_unspecified());
|
||||
assert_eq!(v4_default.prefix_len(), 0);
|
||||
|
||||
let v6_default = IpNet::from_str("::/0").unwrap();
|
||||
assert!(v6_default.addr().is_unspecified());
|
||||
assert_eq!(v6_default.prefix_len(), 0);
|
||||
|
||||
let non_default = IpNet::from_str("10.0.0.0/8").unwrap();
|
||||
assert!(!non_default.addr().is_unspecified() || non_default.prefix_len() != 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forwarding_status_serde() {
|
||||
let status = IpForwardingStatus {
|
||||
ipv4_enabled: true,
|
||||
ipv6_enabled: false,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&status).expect("serialize");
|
||||
assert!(json.contains("\"ipv4_enabled\":true"));
|
||||
assert!(json.contains("\"ipv6_enabled\":false"));
|
||||
|
||||
let deserialized: IpForwardingStatus = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(status, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_variants_formatting() {
|
||||
let err_iface = NetworkError::InterfaceNotFound("wg-test".to_string());
|
||||
assert_eq!(err_iface.to_string(), "interface 'wg-test' not found");
|
||||
|
||||
let err_addr = NetworkError::AddressNotFound("10.0.0.1/24".to_string());
|
||||
assert_eq!(err_addr.to_string(), "address '10.0.0.1/24' not found");
|
||||
|
||||
let err_route = NetworkError::RouteNotFound("192.168.1.0/24".to_string());
|
||||
assert_eq!(err_route.to_string(), "route '192.168.1.0/24' not found");
|
||||
|
||||
let err_netlink = NetworkError::Netlink("Netlink connection refused".to_string());
|
||||
assert_eq!(
|
||||
err_netlink.to_string(),
|
||||
"netlink error: Netlink connection refused"
|
||||
);
|
||||
|
||||
let err_perm = NetworkError::PermissionDenied("Operation requires CAP_NET_ADMIN".to_string());
|
||||
assert_eq!(
|
||||
err_perm.to_string(),
|
||||
"permission denied: Operation requires CAP_NET_ADMIN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_equality_and_filtering() {
|
||||
let now = Utc::now().naive_utc();
|
||||
let r1 = Route {
|
||||
id: Uuid::new_v4(),
|
||||
network_id: None,
|
||||
interface_id: None,
|
||||
destination: IpNet::from_str("172.16.0.0/12").unwrap(),
|
||||
gateway: Some(IpAddr::from_str("10.0.0.254").unwrap()),
|
||||
interface_name: Some("wg0".to_string()),
|
||||
metric: Some(20),
|
||||
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("172.16.0.0/12").unwrap(),
|
||||
gateway: Some(IpAddr::from_str("10.0.0.254").unwrap()),
|
||||
interface_name: Some("wg0".to_string()),
|
||||
metric: Some(20),
|
||||
enabled: false,
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
assert_eq!(r1.destination, r2.destination);
|
||||
assert_eq!(r1.gateway, r2.gateway);
|
||||
assert!(r1.enabled);
|
||||
assert!(!r2.enabled);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Comprehensive unit tests for native nftables translation, deterministic compilation, and safety invariants.
|
||||
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::firewall::{
|
||||
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||
};
|
||||
use nx9_wg_network::engine::{FirewallDiagnostics, NativeLinuxNftablesEngine};
|
||||
use nx9_wg_network::error::NetworkError;
|
||||
use nx9_wg_network::nftables::NftablesRulesetBuilder;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_rule(
|
||||
name: &str,
|
||||
dir: FirewallDirection,
|
||||
action: FirewallAction,
|
||||
proto: FirewallProtocol,
|
||||
src: Option<&str>,
|
||||
dst: Option<&str>,
|
||||
dp: Option<u16>,
|
||||
pr: Option<&str>,
|
||||
priority: i32,
|
||||
enabled: bool,
|
||||
) -> FirewallRule {
|
||||
FirewallRule {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
interface_id: None,
|
||||
peer_id: None,
|
||||
direction: dir,
|
||||
action,
|
||||
protocol: proto,
|
||||
source: src.map(|s| s.to_string()),
|
||||
destination: dst.map(|s| s.to_string()),
|
||||
source_port: None,
|
||||
destination_port: dp,
|
||||
port_range: pr.map(|p| p.to_string()),
|
||||
priority,
|
||||
enabled,
|
||||
description: None,
|
||||
created_at: chrono::Utc::now().naive_utc(),
|
||||
updated_at: chrono::Utc::now().naive_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv4_rule_translation() {
|
||||
let rules = vec![make_rule(
|
||||
"Allow IPv4 Web",
|
||||
FirewallDirection::In,
|
||||
FirewallAction::Accept,
|
||||
FirewallProtocol::Tcp,
|
||||
Some("192.168.1.0/24"),
|
||||
Some("10.0.0.1"),
|
||||
Some(443),
|
||||
None,
|
||||
10,
|
||||
true,
|
||||
)];
|
||||
|
||||
let ruleset = NftablesRulesetBuilder::build(&rules, false, &[]);
|
||||
assert!(ruleset.contains("table inet nx9_wg"));
|
||||
assert!(ruleset.contains("chain input"));
|
||||
assert!(ruleset.contains("tcp ip saddr 192.168.1.0/24 ip daddr 10.0.0.1 tcp dport 443 accept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_rule_translation() {
|
||||
let rules = vec![make_rule(
|
||||
"Allow IPv6 DNS",
|
||||
FirewallDirection::Forward,
|
||||
FirewallAction::Accept,
|
||||
FirewallProtocol::Udp,
|
||||
Some("2001:db8::/64"),
|
||||
Some("2001:db8:ffff::1"),
|
||||
Some(53),
|
||||
None,
|
||||
20,
|
||||
true,
|
||||
)];
|
||||
|
||||
let ruleset = NftablesRulesetBuilder::build(&rules, false, &[]);
|
||||
assert!(ruleset.contains("chain forward"));
|
||||
assert!(
|
||||
ruleset
|
||||
.contains("udp ip6 saddr 2001:db8::/64 ip6 daddr 2001:db8:ffff::1 udp dport 53 accept")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protocol_groups_and_icmp() {
|
||||
let rules = vec![
|
||||
make_rule(
|
||||
"Allow ICMP Ping",
|
||||
FirewallDirection::In,
|
||||
FirewallAction::Accept,
|
||||
FirewallProtocol::Icmp,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
true,
|
||||
),
|
||||
make_rule(
|
||||
"Allow TCP+UDP Services",
|
||||
FirewallDirection::Forward,
|
||||
FirewallAction::Accept,
|
||||
FirewallProtocol::TcpUdp,
|
||||
Some("10.100.0.5"),
|
||||
None,
|
||||
None,
|
||||
Some("53,80,443"),
|
||||
5,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
let ruleset = NftablesRulesetBuilder::build(&rules, false, &[]);
|
||||
assert!(ruleset.contains("ip protocol icmp accept"));
|
||||
assert!(
|
||||
ruleset.contains(
|
||||
"meta l4proto { tcp, udp } ip saddr 10.100.0.5 th dport { 53, 80, 443 } accept"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_ranges_and_single_ports() {
|
||||
let rules = vec![make_rule(
|
||||
"Allow Port Range",
|
||||
FirewallDirection::In,
|
||||
FirewallAction::Accept,
|
||||
FirewallProtocol::Tcp,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("8000-8100"),
|
||||
15,
|
||||
true,
|
||||
)];
|
||||
|
||||
let ruleset = NftablesRulesetBuilder::build(&rules, false, &[]);
|
||||
assert!(ruleset.contains("tcp tcp dport 8000-8100 accept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drop_and_reject_actions() {
|
||||
let rules = vec![
|
||||
make_rule(
|
||||
"Block Bad Subnet",
|
||||
FirewallDirection::In,
|
||||
FirewallAction::Drop,
|
||||
FirewallProtocol::Any,
|
||||
Some("198.51.100.0/24"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
50,
|
||||
true,
|
||||
),
|
||||
make_rule(
|
||||
"Reject Telnet",
|
||||
FirewallDirection::Forward,
|
||||
FirewallAction::Reject,
|
||||
FirewallProtocol::Tcp,
|
||||
None,
|
||||
None,
|
||||
Some(23),
|
||||
None,
|
||||
60,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
let ruleset = NftablesRulesetBuilder::build(&rules, false, &[]);
|
||||
assert!(ruleset.contains("ip saddr 198.51.100.0/24 drop"));
|
||||
assert!(ruleset.contains("tcp tcp dport 23 reject"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nat_masquerade_subnets_scoping() {
|
||||
let v4_subnet: IpNet = "10.100.0.0/24".parse().unwrap();
|
||||
let v6_subnet: IpNet = "fd00:9999::/64".parse().unwrap();
|
||||
|
||||
let ruleset = NftablesRulesetBuilder::build(&[], true, &[v4_subnet, v6_subnet]);
|
||||
assert!(ruleset.contains("chain postrouting"));
|
||||
assert!(ruleset.contains("type nat hook postrouting priority srcnat; policy accept;"));
|
||||
assert!(ruleset.contains("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade"));
|
||||
assert!(ruleset.contains("ip6 saddr fd00:9999::/64 oifname != \"wg*\" masquerade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_priority_ordering() {
|
||||
let rules = vec![
|
||||
make_rule(
|
||||
"Low Priority",
|
||||
FirewallDirection::In,
|
||||
FirewallAction::Accept,
|
||||
FirewallProtocol::Tcp,
|
||||
None,
|
||||
None,
|
||||
Some(80),
|
||||
None,
|
||||
100,
|
||||
true,
|
||||
),
|
||||
make_rule(
|
||||
"High Priority",
|
||||
FirewallDirection::In,
|
||||
FirewallAction::Drop,
|
||||
FirewallProtocol::Tcp,
|
||||
None,
|
||||
None,
|
||||
Some(80),
|
||||
None,
|
||||
10,
|
||||
true,
|
||||
),
|
||||
make_rule(
|
||||
"Disabled Rule",
|
||||
FirewallDirection::In,
|
||||
FirewallAction::Accept,
|
||||
FirewallProtocol::Tcp,
|
||||
None,
|
||||
None,
|
||||
Some(8080),
|
||||
None,
|
||||
5,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let ruleset = NftablesRulesetBuilder::build(&rules, false, &[]);
|
||||
let drop_pos = ruleset.find("tcp tcp dport 80 drop").unwrap();
|
||||
let accept_pos = ruleset.find("tcp tcp dport 80 accept").unwrap();
|
||||
assert!(
|
||||
drop_pos < accept_pos,
|
||||
"Higher priority rule (priority 10) must appear before lower priority rule (priority 100)"
|
||||
);
|
||||
assert!(
|
||||
!ruleset.contains("8080"),
|
||||
"Disabled rule must not appear in generated ruleset"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ownership_validation_rejects_unmanaged_tables() {
|
||||
let engine = NativeLinuxNftablesEngine::new();
|
||||
|
||||
// Rejects global flush
|
||||
let err1 = engine.apply_ruleset("flush ruleset").await.unwrap_err();
|
||||
match err1 {
|
||||
NetworkError::FirewallOwnershipViolation(msg) => {
|
||||
assert!(msg.contains("Refusing to flush global"));
|
||||
}
|
||||
other => panic!("Expected FirewallOwnershipViolation, got: {other:?}"),
|
||||
}
|
||||
|
||||
// Rejects other tables
|
||||
let err2 = engine
|
||||
.apply_ruleset("table ip filter {\n}\n")
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err2 {
|
||||
NetworkError::FirewallOwnershipViolation(msg) => {
|
||||
assert!(msg.contains("Refusing to execute command outside 'table inet nx9_wg'"));
|
||||
}
|
||||
other => panic!("Expected FirewallOwnershipViolation, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_variants_and_formatting() {
|
||||
let err_inv = NetworkError::FirewallRuleInvalid("Port out of bounds".to_string());
|
||||
assert_eq!(
|
||||
err_inv.to_string(),
|
||||
"invalid firewall rule: Port out of bounds"
|
||||
);
|
||||
|
||||
let err_own = NetworkError::FirewallOwnershipViolation("Cannot delete eth0".to_string());
|
||||
assert_eq!(
|
||||
err_own.to_string(),
|
||||
"firewall ownership violation: Cannot delete eth0"
|
||||
);
|
||||
|
||||
let err_nat = NetworkError::NatConfigurationInvalid("Wildcard CIDR not permitted".to_string());
|
||||
assert_eq!(
|
||||
err_nat.to_string(),
|
||||
"invalid NAT configuration: Wildcard CIDR not permitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_firewall_diagnostics_serialization() {
|
||||
let diag = FirewallDiagnostics {
|
||||
table_exists: true,
|
||||
table_name: "nx9_wg".to_string(),
|
||||
family: "inet".to_string(),
|
||||
chain_count: 3,
|
||||
chains: vec![
|
||||
"input".to_string(),
|
||||
"forward".to_string(),
|
||||
"postrouting".to_string(),
|
||||
],
|
||||
rule_count: 5,
|
||||
nat_enabled: true,
|
||||
live_ruleset: Some("table inet nx9_wg { }".to_string()),
|
||||
kernel_status: "active".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&diag).unwrap();
|
||||
assert!(json.contains("\"table_name\":\"nx9_wg\""));
|
||||
assert!(json.contains("\"nat_enabled\":true"));
|
||||
}
|
||||
Reference in new issue
Block a user