From 34227efd2b9e8fea1017528632920d668929ad7e Mon Sep 17 00:00:00 2001 From: Sunil Thakare Date: Tue, 1 Sep 2026 18:21:58 +0530 Subject: [PATCH] fix: include selected networks in dataplane NAT --- crates/nx9-wg-api/src/lib.rs | 1 + crates/nx9-wg-api/src/reconciliation.rs | 50 +++-- crates/nx9-wg-api/src/routes/peers.rs | 153 ++++++++++++-- .../tests/test_road_warrior_dataplane.rs | 145 +++++++++++++ crates/nx9-wireguard/src/engine.rs | 33 ++- crates/nx9-wireguard/src/lib.rs | 2 +- crates/nx9-wireguard/src/native_linux.rs | 192 +++++++++++++++++- docs/FIREWALL_NAT.md | 2 +- docs/NFTABLES.md | 3 +- src/main.rs | 18 +- 10 files changed, 545 insertions(+), 54 deletions(-) diff --git a/crates/nx9-wg-api/src/lib.rs b/crates/nx9-wg-api/src/lib.rs index f7c9ccd..14afc7e 100644 --- a/crates/nx9-wg-api/src/lib.rs +++ b/crates/nx9-wg-api/src/lib.rs @@ -20,6 +20,7 @@ pub use error::{ApiError, ApiResult, ErrorBody, ErrorResponse}; pub use profile_resolver::ClientProfileResolver; pub use reconciliation::{ ReconciliationAction, ReconciliationEngine, ReconciliationPlan, ReconciliationReport, + collect_managed_wg_subnets, }; pub use routes::build_api_router; pub use state::{AppState, SystemEvent}; diff --git a/crates/nx9-wg-api/src/reconciliation.rs b/crates/nx9-wg-api/src/reconciliation.rs index 377159b..d87ec68 100644 --- a/crates/nx9-wg-api/src/reconciliation.rs +++ b/crates/nx9-wg-api/src/reconciliation.rs @@ -6,6 +6,7 @@ use chrono::Utc; use ipnet::IpNet; use nx9_wg_core::types::audit::AuditEventType; use nx9_wg_core::types::wireguard::PeerState; +use nx9_wg_db::Store; use nx9_wg_network::NetworkEngine; use nx9_wireguard::WireGuardEngine; use serde::{Deserialize, Serialize}; @@ -42,6 +43,34 @@ fn matches_allowed_ips(live_allowed_ips: &[String], desired_str: &str) -> bool { desired_nets == live_nets } +/// Collect Interface CIDRs plus enabled Subnet Network CIDRs for NAT/forwarding. +/// +/// Interface addresses remain the WireGuard transport identity. Enabled Network +/// CIDRs are the peer allocation domains and must be masqueraded so selected- +/// Network peers receive the same full-tunnel Internet path as Interface-CIDR +/// peers. `network_id = null` peers still match the Interface CIDR. +pub async fn collect_managed_wg_subnets(store: &Store) -> ApiResult> { + let mut subnets = Vec::new(); + + for iface in store.list_interfaces().await? { + if !iface.enabled { + continue; + } + subnets.push(iface.address_v4); + if let Some(v6) = iface.address_v6 { + subnets.push(v6); + } + } + + for net in store.list_networks().await? { + if net.enabled { + subnets.push(net.cidr); + } + } + + Ok(subnets) +} + /// Individual action proposed or taken by the reconciler. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReconciliationAction { @@ -355,7 +384,7 @@ impl ReconciliationEngine { } } - // 2. Routes + // 2. Routes (SQLite Routes table only; peer-allocation Networks are not routes) let desired_routes = self.state.store.list_routes().await?; let enabled_routes: Vec<_> = desired_routes.iter().filter(|r| r.enabled).collect(); let has_route_drift = self @@ -401,15 +430,7 @@ impl ReconciliationEngine { .map(|s| s.value == "true" || s.value == "1") .unwrap_or(true); - let mut wg_subnets = Vec::new(); - for iface in &desired_interfaces { - if iface.enabled { - wg_subnets.push(iface.address_v4); - if let Some(v6) = iface.address_v6 { - wg_subnets.push(v6); - } - } - } + let wg_subnets = collect_managed_wg_subnets(&self.state.store).await?; let expected_ruleset = nx9_wg_network::NftablesRulesetBuilder::build( &resolved_fw_rules, @@ -484,7 +505,6 @@ impl ReconciliationEngine { let mut details = Vec::new(); // 1. Sync all active WireGuard interfaces and their peers - let mut wg_subnets = Vec::new(); for iface in &desired_interfaces { if iface.enabled { let peers = self.state.store.list_peers_for_interface(iface.id).await?; @@ -497,10 +517,6 @@ impl ReconciliationEngine { iface.name )) })?; - wg_subnets.push(iface.address_v4); - if let Some(v6) = iface.address_v6 { - wg_subnets.push(v6); - } details.push(format!( "Synchronized interface '{}' with {} peers", iface.name, @@ -515,7 +531,9 @@ impl ReconciliationEngine { } } - // 2. Sync Routes + let wg_subnets = collect_managed_wg_subnets(&self.state.store).await?; + + // 2. Sync Routes (SQLite Routes table only; peer-allocation Networks are not routes) let routes = self.state.store.list_routes().await?; self.net_engine .sync_routes(&routes) diff --git a/crates/nx9-wg-api/src/routes/peers.rs b/crates/nx9-wg-api/src/routes/peers.rs index 3168d3a..3af6ebb 100644 --- a/crates/nx9-wg-api/src/routes/peers.rs +++ b/crates/nx9-wg-api/src/routes/peers.rs @@ -16,15 +16,47 @@ use nx9_wg_core::types::wireguard::{ WireGuardPublicKey, }; use nx9_wg_core::validation::{validate_cidr, validate_mtu, validate_peer_name}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use std::str::FromStr; use uuid::Uuid; +/// Deserialize `network_id` from JSON null/empty as None, and from a UUID string as Some. +/// Rejects non-UUID values instead of silently falling back to the Interface CIDR. +fn deserialize_optional_network_id<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Uuid::parse_str(trimmed).map(Some).map_err(|e| { + serde::de::Error::custom(format!("network_id must be a Network UUID: {e}")) + }) + } + } + Some(other) => Err(serde::de::Error::custom(format!( + "network_id must be a UUID string, got {other}" + ))), + } +} + #[derive(Debug, Deserialize)] pub struct CreatePeerRequest { pub name: String, pub peer_type: Option, pub profile: Option, + /// Subnet Network UUID for IP allocation. Also accepts the historical + /// enrollment field name `network` when that value is a UUID. + #[serde( + default, + alias = "network", + deserialize_with = "deserialize_optional_network_id" + )] pub network_id: Option, pub public_key: Option, pub private_key: Option, @@ -264,6 +296,47 @@ async fn validate_no_server_allowed_ips_conflict( Ok(()) } +/// Allocate a peer IPv4 address. +/// +/// When `network_id` is present, allocation MUST use that Network's CIDR and +/// MUST NOT fall back to the WireGuard Interface address space. +/// When `network_id` is absent, preserve the existing Interface CIDR fallback. +async fn allocate_address_v4_for_peer( + store: &nx9_wg_db::Store, + interface: &nx9_wg_core::types::wireguard::Interface, + network_id: Option, +) -> ApiResult { + match network_id { + Some(net_id) => { + let network = store + .get_network(net_id) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Network '{net_id}' not found")))?; + let allocated = + IpAllocator::allocate_next_ip(store, &network, Some(interface), None).await?; + if !network.cidr.contains(&allocated.addr()) { + return Err(ApiError::Internal(format!( + "allocated address {allocated} is outside selected network '{}' ({})", + network.name, network.cidr + ))); + } + Ok(allocated) + } + None => { + let fallback = Network { + id: Uuid::nil(), + name: format!("{}-subnet", interface.name), + cidr: interface.address_v4, + enabled: true, + description: None, + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }; + IpAllocator::allocate_next_ip(store, &fallback, Some(interface), None).await + } + } +} + /// POST /api/v1/interfaces/{id}/peers pub async fn create_peer_handler( State(state): State, @@ -289,28 +362,12 @@ pub async fn create_peer_handler( _ => None, }; - // If address_v4 was not explicitly provided, automatically allocate it + // If address_v4 was not explicitly provided, automatically allocate it. + // A present network_id selects the Subnet Network CIDR; None keeps the + // Interface Network CIDR fallback. These paths are intentionally separate. if address_v4.is_none() { - let net = match payload.network_id { - Some(net_id) => state - .store - .get_network(net_id) - .await? - .ok_or_else(|| ApiError::NotFound(format!("Network '{net_id}' not found")))?, - None => Network { - id: Uuid::nil(), - name: format!("{}-subnet", interface.name), - cidr: interface.address_v4, - enabled: true, - description: None, - created_at: Utc::now().naive_utc(), - updated_at: Utc::now().naive_utc(), - }, - }; - - let allocated = - IpAllocator::allocate_next_ip(&state.store, &net, Some(&interface), None).await?; - address_v4 = Some(allocated); + address_v4 = + Some(allocate_address_v4_for_peer(&state.store, &interface, payload.network_id).await?); } let allowed_ips = match payload.allowed_ips { @@ -794,3 +851,55 @@ pub async fn get_peer_qr_handler( data_url, })) } + +#[cfg(test)] +mod create_peer_request_tests { + use super::CreatePeerRequest; + use uuid::Uuid; + + const NETWORK_UUID: &str = "c2aa62c7-3b9d-43fb-95e7-aa8ab1c71265"; + + #[test] + fn ui_payload_deserializes_network_id_uuid() { + let json = serde_json::json!({ + "name": "sunil-moto-mobile-network-01", + "peer_type": "road_warrior", + "profile": "full_tunnel", + "mtu": 1280, + "persistent_keepalive": 25, + "dns": "1.1.1.1, 1.0.0.1", + "allowed_ips": "0.0.0.0/0, ::/0", + "network_id": NETWORK_UUID + }); + let req: CreatePeerRequest = serde_json::from_value(json).expect("deserialize UI payload"); + assert_eq!(req.network_id, Some(Uuid::parse_str(NETWORK_UUID).unwrap())); + } + + #[test] + fn historical_network_field_uuid_maps_to_network_id() { + let json = serde_json::json!({ + "name": "sunil-moto-mobile-network-01", + "network": NETWORK_UUID + }); + let req: CreatePeerRequest = + serde_json::from_value(json).expect("deserialize historical network field"); + assert_eq!(req.network_id, Some(Uuid::parse_str(NETWORK_UUID).unwrap())); + } + + #[test] + fn null_network_id_deserializes_as_none() { + let json = serde_json::json!({ + "name": "bob-fallback", + "network_id": null + }); + let req: CreatePeerRequest = serde_json::from_value(json).expect("deserialize null"); + assert_eq!(req.network_id, None); + } + + #[test] + fn missing_network_id_deserializes_as_none() { + let json = serde_json::json!({ "name": "bob-fallback" }); + let req: CreatePeerRequest = serde_json::from_value(json).expect("deserialize missing"); + assert_eq!(req.network_id, None); + } +} diff --git a/crates/nx9-wg-api/tests/test_road_warrior_dataplane.rs b/crates/nx9-wg-api/tests/test_road_warrior_dataplane.rs index 0d49520..ad94454 100644 --- a/crates/nx9-wg-api/tests/test_road_warrior_dataplane.rs +++ b/crates/nx9-wg-api/tests/test_road_warrior_dataplane.rs @@ -5,10 +5,12 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use chrono::Utc; use ipnet::IpNet; +use nx9_wg_api::collect_managed_wg_subnets; use nx9_wg_api::reconciliation::ReconciliationEngine; use nx9_wg_api::routes::build_api_router; use nx9_wg_api::state::AppState; use nx9_wg_core::crypto::generate_keypair; +use nx9_wg_core::types::network::Network; use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType}; use nx9_wg_db::Store; use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine}; @@ -344,6 +346,149 @@ async fn test_forwarding_and_nat_reconciliation_invariants() { assert_eq!(plan.interface_changes, 0); } +#[tokio::test] +async fn test_selected_network_dataplane_nat_and_routes() { + let (state, iface, _peer, _session_id) = setup_test_context().await; + let now = Utc::now().naive_utc(); + + let network = Network { + id: Uuid::new_v4(), + name: "mobile-clients".to_string(), + cidr: IpNet::from_str("10.100.2.0/24").unwrap(), + enabled: true, + description: None, + created_at: now, + updated_at: now, + }; + state.store.create_network(&network).await.unwrap(); + + let (peer_priv, peer_pub) = generate_keypair(); + let selected_peer = Peer { + id: Uuid::new_v4(), + interface_id: iface.id, + name: "test-mobile".to_string(), + peer_type: PeerType::RoadWarrior, + state: PeerState::Active, + public_key: peer_pub, + private_key: Some(peer_priv), + preshared_key: None, + endpoint: None, + allowed_ips: "0.0.0.0/0, ::/0".to_string(), + server_allowed_ips: None, + address_v4: Some(IpNet::from_str("10.100.2.1/32").unwrap()), + address_v6: None, + dns: Some("1.1.1.1, 1.0.0.1".to_string()), + mtu: Some(1280), + persistent_keepalive: Some(25), + profile: PeerProfile::FullTunnel, + expires_at: None, + last_handshake_at: None, + created_at: now, + updated_at: now, + }; + state.store.create_peer(&selected_peer).await.unwrap(); + + assert_eq!( + selected_peer.server_wireguard_allowed_ips(), + "10.100.2.1/32", + "server-side AllowedIPs must remain the assigned selected-Network address" + ); + assert_eq!(selected_peer.allowed_ips, "0.0.0.0/0, ::/0"); + + let subnets = collect_managed_wg_subnets(&state.store).await.unwrap(); + assert!( + subnets + .iter() + .any(|s| s.trunc().to_string() == "10.100.0.0/24"), + "Interface CIDR must remain in managed NAT subnets" + ); + assert!( + subnets + .iter() + .any(|s| s.trunc().to_string() == "10.100.2.0/24"), + "selected Network CIDR must participate in managed NAT subnets" + ); + + let wg_engine = Arc::new(SimulatedWireGuardEngine::new()); + let net_engine = Arc::new(SimulatedNetworkEngine::new()); + let reconciler = + ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone()); + + let report = reconciler.apply().await.unwrap(); + assert!(report.success); + + let persisted_iface = state.store.get_interface(iface.id).await.unwrap().unwrap(); + assert_eq!(persisted_iface.address_v4.to_string(), "10.100.0.1/24"); + assert_eq!(persisted_iface.name, "wg0"); + let stored_routes = state.store.list_routes().await.unwrap(); + assert!( + !stored_routes + .iter() + .any(|r| r.destination.trunc().to_string() == "10.100.2.0/24"), + "peer-allocation Network CIDR must not be persisted as a static route" + ); + + let ruleset = net_engine.get_active_nftables_ruleset().await.unwrap(); + assert!( + ruleset.contains("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade"), + "Interface-CIDR peers must keep existing NAT: {ruleset}" + ); + assert!( + ruleset.contains("ip saddr 10.100.2.0/24 oifname != \"wg*\" masquerade"), + "selected Network CIDR must be masqueraded for full-tunnel Internet: {ruleset}" + ); + + let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap(); + assert!( + live_stats + .peers + .iter() + .any(|p| p.allowed_ips.iter().any(|a| a == "10.100.2.1/32")), + "kernel peer AllowedIPs must include the selected-Network assignment" + ); + + let (fallback_priv, fallback_pub) = generate_keypair(); + let fallback_peer = Peer { + id: Uuid::new_v4(), + interface_id: iface.id, + name: "fallback-null-network".to_string(), + peer_type: PeerType::RoadWarrior, + state: PeerState::Active, + public_key: fallback_pub, + private_key: Some(fallback_priv), + preshared_key: None, + endpoint: None, + allowed_ips: "0.0.0.0/0, ::/0".to_string(), + server_allowed_ips: None, + address_v4: Some(IpNet::from_str("10.100.0.2/32").unwrap()), + address_v6: None, + dns: None, + mtu: None, + persistent_keepalive: Some(25), + profile: PeerProfile::FullTunnel, + expires_at: None, + last_handshake_at: None, + created_at: now, + updated_at: now, + }; + state.store.create_peer(&fallback_peer).await.unwrap(); + assert_eq!( + fallback_peer.server_wireguard_allowed_ips(), + "10.100.0.2/32" + ); + + let report = reconciler.apply().await.unwrap(); + assert!(report.success); + let ruleset = net_engine.get_active_nftables_ruleset().await.unwrap(); + assert!(ruleset.contains("ip saddr 10.100.0.0/24 oifname != \"wg*\" masquerade")); + assert!(ruleset.contains("ip saddr 10.100.2.0/24 oifname != \"wg*\" masquerade")); + + let plan = reconciler.plan().await.unwrap(); + assert!(!plan.has_drift); + assert_eq!(plan.firewall_changes, 0); + assert_eq!(plan.route_changes, 0); +} + #[tokio::test] async fn test_interface_editing_persistence_and_key_preservation() { let (state, iface, _peer, session_id) = setup_test_context().await; diff --git a/crates/nx9-wireguard/src/engine.rs b/crates/nx9-wireguard/src/engine.rs index 5ae457f..d764254 100644 --- a/crates/nx9-wireguard/src/engine.rs +++ b/crates/nx9-wireguard/src/engine.rs @@ -2,9 +2,10 @@ use crate::error::{Result, WireGuardError}; use chrono::{NaiveDateTime, Utc}; +use ipnet::IpNet; use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use tokio::sync::RwLock; @@ -36,6 +37,36 @@ pub struct LiveInterfaceStats { pub is_up: bool, } +/// Peer tunnel addresses that are not on the Interface connected prefix. +/// +/// Interface-CIDR peers (e.g. 10.100.0.x with wg0 10.100.0.1/24) are already +/// reachable via the kernel connected route created by the interface address. +/// Selected-Network peers (e.g. 10.100.2.1/32) are not. Those prefixes must be +/// installed as on-link device routes on the WireGuard interface so the FIB +/// delivers packets into wg0, where cryptokey routing (AllowedIPs) applies. +/// +/// This is not a Routes-table LAN-behind-peer destination and has no gateway. +pub fn onlink_peer_address_prefixes(interface: &Interface, peers: &[Peer]) -> Vec { + let mut prefixes = BTreeSet::new(); + for peer in peers.iter().filter(|p| p.state == PeerState::Active) { + if let Some(v4) = peer.address_v4 + && v4.prefix_len() > 0 + && !interface.address_v4.contains(&v4.addr()) + { + prefixes.insert(v4); + } + if let Some(v6) = peer.address_v6 + && v6.prefix_len() > 0 + && !interface + .address_v6 + .is_some_and(|iface_v6| iface_v6.contains(&v6.addr())) + { + prefixes.insert(v6); + } + } + prefixes.into_iter().collect() +} + /// Abstract WireGuard Engine interface for kernel netlink and simulated environments. #[async_trait::async_trait] pub trait WireGuardEngine: Send + Sync { diff --git a/crates/nx9-wireguard/src/lib.rs b/crates/nx9-wireguard/src/lib.rs index 2ec4060..08fdb95 100644 --- a/crates/nx9-wireguard/src/lib.rs +++ b/crates/nx9-wireguard/src/lib.rs @@ -10,7 +10,7 @@ pub mod qr; pub use config_builder::ClientConfigBuilder; pub use engine::{ LiveInterfaceStats, LivePeerStats, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine, - WireGuardEngine, + WireGuardEngine, onlink_peer_address_prefixes, }; pub use error::{Result, WireGuardError}; pub use qr::{ diff --git a/crates/nx9-wireguard/src/native_linux.rs b/crates/nx9-wireguard/src/native_linux.rs index be86866..2809479 100644 --- a/crates/nx9-wireguard/src/native_linux.rs +++ b/crates/nx9-wireguard/src/native_linux.rs @@ -8,7 +8,9 @@ //! //! No external commands (wg, ip, wg-quick, nft, sysctl) are ever executed. -use crate::engine::{LiveInterfaceStats, LivePeerStats, WireGuardEngine}; +use crate::engine::{ + LiveInterfaceStats, LivePeerStats, WireGuardEngine, onlink_peer_address_prefixes, +}; use crate::error::{Result, WireGuardError}; use base64::Engine as _; use chrono::NaiveDateTime; @@ -24,9 +26,13 @@ use netlink_packet_wireguard::{ }; use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState}; use rtnetlink::LinkWireguard; +use rtnetlink::RouteMessageBuilder; +use rtnetlink::packet_route::AddressFamily; use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage}; use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo}; -use std::net::{IpAddr, SocketAddr}; +use rtnetlink::packet_route::route::{RouteAddress, RouteAttribute, RouteMessage}; +use std::collections::HashSet; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; /// Linux Native WireGuard Engine using kernel RTNETLINK and Generic Netlink. #[derive(Debug, Clone, Default)] @@ -852,6 +858,178 @@ fn parse_endpoint(s: &str) -> Result { ))) } +/// Install on-link device routes for peer tunnel addresses outside the Interface prefix. +/// +/// Interface-CIDR peers are already covered by the connected route from the +/// interface address. Selected-Network peer addresses are not; without a FIB +/// path into wg0, cryptokey routing never sees the packet. Routes have no +/// gateway and are not Routes-table LAN destinations. +async fn ensure_onlink_peer_routes(interface: &Interface, peers: &[Peer]) -> Result<()> { + let (handle, _join) = rtnetlink_handle().await?; + + let mut links = handle + .link() + .get() + .match_name(interface.name.to_string()) + .execute(); + let Some(link) = links.try_next().await.map_err(|e| { + WireGuardError::Netlink(format!( + "failed to resolve interface '{}' for on-link routes: {e}", + interface.name + )) + })? + else { + return Ok(()); + }; + let link_index = link.header.index; + + let desired: HashSet = onlink_peer_address_prefixes(interface, peers) + .into_iter() + .collect(); + + let live = list_onlink_routes_for_index(&handle, link_index).await?; + + for prefix in &desired { + if live.contains(prefix) { + continue; + } + add_onlink_device_route(&handle, link_index, *prefix).await?; + } + + let iface_v4 = interface.address_v4.trunc(); + let iface_v6 = interface.address_v6.map(|n| n.trunc()); + for prefix in live { + let is_host = matches!(prefix, IpNet::V4(n) if n.prefix_len() == 32) + || matches!(prefix, IpNet::V6(n) if n.prefix_len() == 128); + if !is_host { + continue; + } + if prefix.trunc() == iface_v4 || iface_v6 == Some(prefix.trunc()) { + continue; + } + if desired.contains(&prefix) { + continue; + } + let _ = delete_onlink_device_route(&handle, link_index, prefix).await; + } + + Ok(()) +} + +async fn list_onlink_routes_for_index( + handle: &rtnetlink::Handle, + link_index: u32, +) -> Result> { + let mut results = HashSet::new(); + for family in [AddressFamily::Inet, AddressFamily::Inet6] { + let mut req = RouteMessage::default(); + req.header.address_family = family; + let mut stream = handle.route().get(req).execute(); + while let Some(msg) = stream + .try_next() + .await + .map_err(|e| WireGuardError::Netlink(format!("RTNETLINK route dump failed: {e}")))? + { + let mut dest_ip = match family { + AddressFamily::Inet => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + AddressFamily::Inet6 => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + _ => continue, + }; + let mut oif = None; + let mut has_gateway = false; + 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(_) => has_gateway = true, + RouteAttribute::Oif(idx) => oif = Some(*idx), + _ => {} + } + } + if has_gateway || oif != Some(link_index) { + continue; + } + if let Ok(net) = IpNet::new(dest_ip, msg.header.destination_prefix_length) { + results.insert(net); + } + } + } + Ok(results) +} + +async fn add_onlink_device_route( + handle: &rtnetlink::Handle, + link_index: u32, + prefix: IpNet, +) -> Result<()> { + let exec_result = match prefix { + IpNet::V4(v4) => { + let msg = RouteMessageBuilder::::new() + .destination_prefix(v4.addr(), v4.prefix_len()) + .output_interface(link_index) + .build(); + handle.route().add(msg).execute().await + } + IpNet::V6(v6) => { + let msg = RouteMessageBuilder::::new() + .destination_prefix(v6.addr(), v6.prefix_len()) + .output_interface(link_index) + .build(); + handle.route().add(msg).execute().await + } + }; + if let Err(e) = exec_result { + let err_str = e.to_string(); + if err_str.contains("File exists") || err_str.contains("17") { + return Ok(()); + } + if err_str.contains("permission") + || err_str.contains("EPERM") + || err_str.contains("Operation not permitted") + { + return Err(WireGuardError::PermissionDenied(format!( + "insufficient privileges to add on-link route '{prefix}': {e}" + ))); + } + return Err(WireGuardError::Netlink(format!( + "failed to add on-link route '{prefix}': {e}" + ))); + } + tracing::info!(prefix = %prefix, "On-link peer address route added via RTNETLINK"); + Ok(()) +} + +async fn delete_onlink_device_route( + handle: &rtnetlink::Handle, + link_index: u32, + prefix: IpNet, +) -> Result<()> { + let exec_result = match prefix { + IpNet::V4(v4) => { + let msg = RouteMessageBuilder::::new() + .destination_prefix(v4.addr(), v4.prefix_len()) + .output_interface(link_index) + .build(); + handle.route().del(msg).execute().await + } + IpNet::V6(v6) => { + let msg = RouteMessageBuilder::::new() + .destination_prefix(v6.addr(), v6.prefix_len()) + .output_interface(link_index) + .build(); + handle.route().del(msg).execute().await + } + }; + if let Err(e) = exec_result { + tracing::debug!(prefix = %prefix, error = %e, "On-link peer address route delete skipped"); + } + Ok(()) +} + // ── WireGuardEngine Trait Implementation ───────────────────────────────────── #[async_trait::async_trait] @@ -863,6 +1041,16 @@ impl WireGuardEngine for NativeLinuxWireGuardEngine { // 2. Configure the WireGuard device (private key, listen port, peers) configure_device(interface, peers).await?; + // 3. On-link device routes for peer tunnel addresses outside the + // Interface connected prefix (cryptokey routing still uses AllowedIPs). + if let Err(e) = ensure_onlink_peer_routes(interface, peers).await { + tracing::warn!( + interface = %interface.name, + error = %e, + "On-link peer address routes skipped" + ); + } + tracing::info!( interface = %interface.name, active_peers = peers.iter().filter(|p| p.state == PeerState::Active).count(), diff --git a/docs/FIREWALL_NAT.md b/docs/FIREWALL_NAT.md index f9d2806..8750bca 100644 --- a/docs/FIREWALL_NAT.md +++ b/docs/FIREWALL_NAT.md @@ -53,7 +53,7 @@ The `port_range` field supports three RFC-compliant formats: NAT masquerading is governed by key-value appliance settings in SQLite: - **`enable_nat`**: Boolean string (`"true"` / `"false"`). When enabled, all active managed WireGuard subnets are masqueraded outbound to the host WAN interface. -- **Dynamic Subnet Calculation**: The reconciliation engine queries all enabled interfaces (`Interface.address_v4`) and generates dedicated masquerade rules for each unique subnet. +- **Dynamic Subnet Calculation**: The reconciliation engine queries enabled Interface address CIDRs and enabled Subnet Network CIDRs, then generates dedicated masquerade rules for each unique subnet. Interface addresses remain the WireGuard transport identity; Network CIDRs are the peer allocation domains. --- diff --git a/docs/NFTABLES.md b/docs/NFTABLES.md index 793dc3c..1883f21 100644 --- a/docs/NFTABLES.md +++ b/docs/NFTABLES.md @@ -66,8 +66,9 @@ table inet nx9_wg { Outbound NAT masquerading is dynamically scoped exclusively to managed WireGuard client subnets: 1. **Subnet Deduplication**: Overlapping subnets are merged to prevent redundant rules. -2. **Interface Exclusion**: Traffic routing back into the WireGuard interface (`oifname != "wg0"`) is not masqueraded to preserve true source IPs for site-to-site tunnels. +2. **Interface Exclusion**: Traffic routing back into the WireGuard interface (`oifname != "wg*"`) is not masqueraded to preserve true source IPs for site-to-site tunnels. 3. **No Catch-All Masquerade**: `nx9-wg` never creates a catch-all `masquerade` rule that affects non-WireGuard traffic on the host. +4. **Interface and Subnet Network CIDRs**: Masquerade sources include each enabled Interface address CIDR and each enabled Subnet Network CIDR. A peer allocated from a selected Network (for example outside the WireGuard interface `/24`) is masqueraded from that Network CIDR; the Interface address itself is unchanged. --- diff --git a/src/main.rs b/src/main.rs index 7d63f0d..f810250 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2614,8 +2614,7 @@ async fn main() -> Result<(), Box> { } FirewallSubcommands::Sync => { let rules = store.list_firewall_rules().await?; - let ifaces = store.list_interfaces().await?; - let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect(); + let subnets = nx9_wg_api::collect_managed_wg_subnets(&store).await?; let net = NativeLinuxNetworkEngine::new(); net.sync_firewall(&rules, true, &subnets).await?; println!("Firewall ruleset synchronized successfully."); @@ -2639,10 +2638,10 @@ async fn main() -> Result<(), Box> { match args.subcommand { NatSubcommands::Status => { - let ifaces = store.list_interfaces().await?; - let subnets: Vec = ifaces + let subnets: Vec = nx9_wg_api::collect_managed_wg_subnets(&store) + .await? .into_iter() - .map(|i| i.address_v4.to_string()) + .map(|s| s.to_string()) .collect(); let status = serde_json::json!({ "nat_masquerade_enabled": true, @@ -2660,17 +2659,16 @@ async fn main() -> Result<(), Box> { println!("NAT masquerade disabled in settings."); } NatSubcommands::List => { - let ifaces = store.list_interfaces().await?; - let subnets: Vec = ifaces + let subnets: Vec = nx9_wg_api::collect_managed_wg_subnets(&store) + .await? .into_iter() - .map(|i| i.address_v4.to_string()) + .map(|s| s.to_string()) .collect(); print_output(&subnets, format)?; } NatSubcommands::Sync => { let rules = store.list_firewall_rules().await?; - let ifaces = store.list_interfaces().await?; - let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect(); + let subnets = nx9_wg_api::collect_managed_wg_subnets(&store).await?; let net = NativeLinuxNetworkEngine::new(); net.sync_firewall(&rules, true, &subnets).await?; println!("NAT masquerade rules synchronized with nftables.");