Files
nx9-wg/crates/nx9-wg-network/src/native_linux.rs
T

985 lines
34 KiB
Rust

//! 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)
}
}