fix: include selected networks in dataplane NAT
This commit is contained in:
1 parent
5599e1b5c8
commit
34227efd2b
10 files changed
+545
-54
No files matched your search
@@ -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};
|
||||
@@ -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<Vec<IpNet>> {
|
||||
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)
|
||||
|
||||
@@ -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<Option<Uuid>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<serde_json::Value>::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<PeerType>,
|
||||
pub profile: Option<PeerProfile>,
|
||||
/// 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<Uuid>,
|
||||
pub public_key: Option<String>,
|
||||
pub private_key: Option<String>,
|
||||
@@ -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<Uuid>,
|
||||
) -> ApiResult<IpNet> {
|
||||
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<AppState>,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user