Compare commits
2
Commits
32a325234a
...
34227efd2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34227efd2b | ||
|
|
5599e1b5c8 |
No files matched your search
@@ -20,6 +20,7 @@ pub use error::{ApiError, ApiResult, ErrorBody, ErrorResponse};
|
|||||||
pub use profile_resolver::ClientProfileResolver;
|
pub use profile_resolver::ClientProfileResolver;
|
||||||
pub use reconciliation::{
|
pub use reconciliation::{
|
||||||
ReconciliationAction, ReconciliationEngine, ReconciliationPlan, ReconciliationReport,
|
ReconciliationAction, ReconciliationEngine, ReconciliationPlan, ReconciliationReport,
|
||||||
|
collect_managed_wg_subnets,
|
||||||
};
|
};
|
||||||
pub use routes::build_api_router;
|
pub use routes::build_api_router;
|
||||||
pub use state::{AppState, SystemEvent};
|
pub use state::{AppState, SystemEvent};
|
||||||
@@ -6,6 +6,7 @@ use chrono::Utc;
|
|||||||
use ipnet::IpNet;
|
use ipnet::IpNet;
|
||||||
use nx9_wg_core::types::audit::AuditEventType;
|
use nx9_wg_core::types::audit::AuditEventType;
|
||||||
use nx9_wg_core::types::wireguard::PeerState;
|
use nx9_wg_core::types::wireguard::PeerState;
|
||||||
|
use nx9_wg_db::Store;
|
||||||
use nx9_wg_network::NetworkEngine;
|
use nx9_wg_network::NetworkEngine;
|
||||||
use nx9_wireguard::WireGuardEngine;
|
use nx9_wireguard::WireGuardEngine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -42,6 +43,34 @@ fn matches_allowed_ips(live_allowed_ips: &[String], desired_str: &str) -> bool {
|
|||||||
desired_nets == live_nets
|
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.
|
/// Individual action proposed or taken by the reconciler.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ReconciliationAction {
|
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 desired_routes = self.state.store.list_routes().await?;
|
||||||
let enabled_routes: Vec<_> = desired_routes.iter().filter(|r| r.enabled).collect();
|
let enabled_routes: Vec<_> = desired_routes.iter().filter(|r| r.enabled).collect();
|
||||||
let has_route_drift = self
|
let has_route_drift = self
|
||||||
@@ -401,15 +430,7 @@ impl ReconciliationEngine {
|
|||||||
.map(|s| s.value == "true" || s.value == "1")
|
.map(|s| s.value == "true" || s.value == "1")
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
let mut wg_subnets = Vec::new();
|
let wg_subnets = collect_managed_wg_subnets(&self.state.store).await?;
|
||||||
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 expected_ruleset = nx9_wg_network::NftablesRulesetBuilder::build(
|
let expected_ruleset = nx9_wg_network::NftablesRulesetBuilder::build(
|
||||||
&resolved_fw_rules,
|
&resolved_fw_rules,
|
||||||
@@ -484,7 +505,6 @@ impl ReconciliationEngine {
|
|||||||
let mut details = Vec::new();
|
let mut details = Vec::new();
|
||||||
|
|
||||||
// 1. Sync all active WireGuard interfaces and their peers
|
// 1. Sync all active WireGuard interfaces and their peers
|
||||||
let mut wg_subnets = Vec::new();
|
|
||||||
for iface in &desired_interfaces {
|
for iface in &desired_interfaces {
|
||||||
if iface.enabled {
|
if iface.enabled {
|
||||||
let peers = self.state.store.list_peers_for_interface(iface.id).await?;
|
let peers = self.state.store.list_peers_for_interface(iface.id).await?;
|
||||||
@@ -497,10 +517,6 @@ impl ReconciliationEngine {
|
|||||||
iface.name
|
iface.name
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
wg_subnets.push(iface.address_v4);
|
|
||||||
if let Some(v6) = iface.address_v6 {
|
|
||||||
wg_subnets.push(v6);
|
|
||||||
}
|
|
||||||
details.push(format!(
|
details.push(format!(
|
||||||
"Synchronized interface '{}' with {} peers",
|
"Synchronized interface '{}' with {} peers",
|
||||||
iface.name,
|
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?;
|
let routes = self.state.store.list_routes().await?;
|
||||||
self.net_engine
|
self.net_engine
|
||||||
.sync_routes(&routes)
|
.sync_routes(&routes)
|
||||||
|
|||||||
@@ -630,7 +630,7 @@
|
|||||||
<label class="form-label">Network</label>
|
<label class="form-label">Network</label>
|
||||||
<select id="peer-network" class="form-select">
|
<select id="peer-network" class="form-select">
|
||||||
<option value="">Auto-allocate next IP</option>
|
<option value="">Auto-allocate next IP</option>
|
||||||
${networksData.map(n => `<option value="${n.name}">${escapeHtml(n.name)} (${n.cidr})</option>`).join('')}
|
${networksData.map(n => n && n.id ? `<option value="${n.id}">${escapeHtml(n.name)} (${n.cidr})</option>` : '').join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,13 +668,17 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isNetworkUuid(value) {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || ''));
|
||||||
|
}
|
||||||
|
|
||||||
window.submitCreatePeer = async function() {
|
window.submitCreatePeer = async function() {
|
||||||
const errBox = document.getElementById('peer-modal-error');
|
const errBox = document.getElementById('peer-modal-error');
|
||||||
if (errBox) errBox.style.display = 'none';
|
if (errBox) errBox.style.display = 'none';
|
||||||
|
|
||||||
const name = document.getElementById('peer-name')?.value?.trim();
|
const name = document.getElementById('peer-name')?.value?.trim();
|
||||||
const ifaceId = document.getElementById('peer-iface')?.value;
|
const ifaceId = document.getElementById('peer-iface')?.value;
|
||||||
const network = document.getElementById('peer-network')?.value;
|
const rawNetworkValue = (document.getElementById('peer-network')?.value || '').trim();
|
||||||
const mtu = parseInt(document.getElementById('rec-mtu-val')?.textContent || '1420', 10);
|
const mtu = parseInt(document.getElementById('rec-mtu-val')?.textContent || '1420', 10);
|
||||||
|
|
||||||
if (!name || !ifaceId) {
|
if (!name || !ifaceId) {
|
||||||
@@ -685,6 +689,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the selector back to the Network API object and send only its UUID.
|
||||||
|
// Display text is name + CIDR; the request field must never be the name or CIDR.
|
||||||
|
let networkId = null;
|
||||||
|
if (rawNetworkValue) {
|
||||||
|
const selectedNetwork = networksData.find(n => n && String(n.id) === rawNetworkValue);
|
||||||
|
const resolvedId = selectedNetwork ? String(selectedNetwork.id) : rawNetworkValue;
|
||||||
|
if (!isNetworkUuid(resolvedId)) {
|
||||||
|
if (errBox) {
|
||||||
|
errBox.style.display = 'block';
|
||||||
|
errBox.textContent = '❌ Selected Network is missing a valid UUID. Refresh the page and try again.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
networkId = resolvedId;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
peer_type: 'road_warrior',
|
peer_type: 'road_warrior',
|
||||||
@@ -693,7 +713,7 @@
|
|||||||
persistent_keepalive: 25,
|
persistent_keepalive: 25,
|
||||||
dns: '1.1.1.1, 1.0.0.1',
|
dns: '1.1.1.1, 1.0.0.1',
|
||||||
allowed_ips: '0.0.0.0/0, ::/0',
|
allowed_ips: '0.0.0.0/0, ::/0',
|
||||||
network: network || null
|
network_id: networkId
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await api(`/interfaces/${ifaceId}/peers`, {
|
const res = await api(`/interfaces/${ifaceId}/peers`, {
|
||||||
@@ -1547,15 +1567,17 @@
|
|||||||
|
|
||||||
// ── NAT & Masquerade ────────────────────────────────────────────────────────
|
// ── NAT & Masquerade ────────────────────────────────────────────────────────
|
||||||
async function renderNatPage(container) {
|
async function renderNatPage(container) {
|
||||||
const [settings, ifaces] = await Promise.all([
|
const [settings, ifaces, networks] = await Promise.all([
|
||||||
api('/system/settings'),
|
api('/system/settings'),
|
||||||
api('/interfaces')
|
api('/interfaces'),
|
||||||
|
api('/networks')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const settingList = Array.isArray(settings) ? settings : [];
|
const settingList = Array.isArray(settings) ? settings : [];
|
||||||
const natSetting = settingList.find(s => s.key === 'enable_nat');
|
const natSetting = settingList.find(s => s.key === 'enable_nat');
|
||||||
const isNatEnabled = natSetting ? (natSetting.value === 'true' || natSetting.value === '1') : true;
|
const isNatEnabled = natSetting ? (natSetting.value === 'true' || natSetting.value === '1') : true;
|
||||||
const ifaceList = Array.isArray(ifaces) ? ifaces : [];
|
const ifaceList = Array.isArray(ifaces) ? ifaces : [];
|
||||||
|
const networkList = Array.isArray(networks) ? networks.filter(n => n && n.enabled !== false) : [];
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -1587,7 +1609,8 @@
|
|||||||
<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
The following subnets are dynamically deduplicated and translated to the host WAN IP:
|
The following subnets are dynamically deduplicated and translated to the host WAN IP:
|
||||||
</div>
|
</div>
|
||||||
${ifaceList.map(i => `<div style="font-size: 13px; padding: 4px 0;"><span class="key-code">${i.address_v4}</span> (${i.name})</div>`).join('')}
|
${ifaceList.map(i => `<div style="font-size: 13px; padding: 4px 0;"><span class="key-code">${i.address_v4}</span> (${escapeHtml(i.name)})</div>`).join('')}
|
||||||
|
${networkList.map(n => `<div style="font-size: 13px; padding: 4px 0;"><span class="key-code">${n.cidr}</span> (${escapeHtml(n.name)})</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -16,15 +16,47 @@ use nx9_wg_core::types::wireguard::{
|
|||||||
WireGuardPublicKey,
|
WireGuardPublicKey,
|
||||||
};
|
};
|
||||||
use nx9_wg_core::validation::{validate_cidr, validate_mtu, validate_peer_name};
|
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 std::str::FromStr;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreatePeerRequest {
|
pub struct CreatePeerRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub peer_type: Option<PeerType>,
|
pub peer_type: Option<PeerType>,
|
||||||
pub profile: Option<PeerProfile>,
|
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 network_id: Option<Uuid>,
|
||||||
pub public_key: Option<String>,
|
pub public_key: Option<String>,
|
||||||
pub private_key: Option<String>,
|
pub private_key: Option<String>,
|
||||||
@@ -264,6 +296,47 @@ async fn validate_no_server_allowed_ips_conflict(
|
|||||||
Ok(())
|
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
|
/// POST /api/v1/interfaces/{id}/peers
|
||||||
pub async fn create_peer_handler(
|
pub async fn create_peer_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -289,28 +362,12 @@ pub async fn create_peer_handler(
|
|||||||
_ => None,
|
_ => 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() {
|
if address_v4.is_none() {
|
||||||
let net = match payload.network_id {
|
address_v4 =
|
||||||
Some(net_id) => state
|
Some(allocate_address_v4_for_peer(&state.store, &interface, payload.network_id).await?);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowed_ips = match payload.allowed_ips {
|
let allowed_ips = match payload.allowed_ips {
|
||||||
@@ -794,3 +851,55 @@ pub async fn get_peer_qr_handler(
|
|||||||
data_url,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -409,3 +409,134 @@ async fn test_list_all_peers_collection_endpoint() {
|
|||||||
assert_eq!(iface1_peers.len(), 1, "wg1 must return exactly 1 peer");
|
assert_eq!(iface1_peers.len(), 1, "wg1 must return exactly 1 peer");
|
||||||
assert_eq!(iface1_peers[0]["name"], "peer-charlie");
|
assert_eq!(iface1_peers[0]["name"], "peer-charlie");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_peer_creation_allocates_from_selected_network() {
|
||||||
|
let (app, cookie) = setup_test_app().await;
|
||||||
|
|
||||||
|
// Interface Network (WireGuard transport address space)
|
||||||
|
let create_iface_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/interfaces")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "wg0",
|
||||||
|
"listen_port": 51820,
|
||||||
|
"address_v4": "10.100.0.1/24"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let iface_val: Value =
|
||||||
|
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||||
|
let iface_id = iface_val["id"].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(iface_val["address_v4"], "10.100.0.1/24");
|
||||||
|
|
||||||
|
// Subnet Network (peer allocation domain)
|
||||||
|
let create_net_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/networks")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "mobile-clients",
|
||||||
|
"cidr": "10.100.2.0/24"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(create_net_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let net_val: Value =
|
||||||
|
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||||
|
let network_id = net_val["id"].as_str().unwrap();
|
||||||
|
assert_eq!(net_val["cidr"], "10.100.2.0/24");
|
||||||
|
|
||||||
|
// Exact production enrollment payload: selected Subnet Network UUID as network_id.
|
||||||
|
let selected_peer_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
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_id
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(selected_peer_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let selected_peer: Value =
|
||||||
|
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||||
|
let selected_addr = selected_peer["address_v4"].as_str().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
selected_addr, "10.100.2.1/32",
|
||||||
|
"selected Network must allocate the first host of 10.100.2.0/24, got {selected_addr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
selected_addr.starts_with("10.100.2."),
|
||||||
|
"selected Network must allocate from 10.100.2.0/24, got {selected_addr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!selected_addr.starts_with("10.100.0."),
|
||||||
|
"must not allocate from Interface Network 10.100.0.0/24 when a Subnet Network is selected, got {selected_addr}"
|
||||||
|
);
|
||||||
|
assert!(selected_addr.ends_with("/32"));
|
||||||
|
|
||||||
|
// network_id = null preserves existing fallback (Interface Network CIDR)
|
||||||
|
let fallback_peer_req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"name": "bob-fallback",
|
||||||
|
"peer_type": "road_warrior",
|
||||||
|
"profile": "full_tunnel",
|
||||||
|
"network_id": null
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(fallback_peer_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let fallback_peer: Value =
|
||||||
|
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||||
|
let fallback_addr = fallback_peer["address_v4"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
fallback_addr.starts_with("10.100.0."),
|
||||||
|
"network_id=null must preserve fallback allocation from Interface Network 10.100.0.0/24, got {fallback_addr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!fallback_addr.starts_with("10.100.2."),
|
||||||
|
"network_id=null must not allocate from a Subnet Network, got {fallback_addr}"
|
||||||
|
);
|
||||||
|
assert!(fallback_addr.ends_with("/32"));
|
||||||
|
|
||||||
|
// WireGuard interface address space is unchanged
|
||||||
|
let get_iface_req = Request::builder()
|
||||||
|
.uri(format!("/api/v1/interfaces/{iface_id}"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(get_iface_req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let iface_after: Value =
|
||||||
|
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||||
|
assert_eq!(iface_after["name"], "wg0");
|
||||||
|
assert_eq!(iface_after["address_v4"], "10.100.0.1/24");
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ use axum::body::Body;
|
|||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use ipnet::IpNet;
|
use ipnet::IpNet;
|
||||||
|
use nx9_wg_api::collect_managed_wg_subnets;
|
||||||
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
||||||
use nx9_wg_api::routes::build_api_router;
|
use nx9_wg_api::routes::build_api_router;
|
||||||
use nx9_wg_api::state::AppState;
|
use nx9_wg_api::state::AppState;
|
||||||
use nx9_wg_core::crypto::generate_keypair;
|
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_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||||
use nx9_wg_db::Store;
|
use nx9_wg_db::Store;
|
||||||
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
|
||||||
@@ -344,6 +346,149 @@ async fn test_forwarding_and_nat_reconciliation_invariants() {
|
|||||||
assert_eq!(plan.interface_changes, 0);
|
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]
|
#[tokio::test]
|
||||||
async fn test_interface_editing_persistence_and_key_preservation() {
|
async fn test_interface_editing_persistence_and_key_preservation() {
|
||||||
let (state, iface, _peer, session_id) = setup_test_context().await;
|
let (state, iface, _peer, session_id) = setup_test_context().await;
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
|||||||
assert!(html.contains("triggerCreateBackup"));
|
assert!(html.contains("triggerCreateBackup"));
|
||||||
assert!(html.contains("openClientExportModal"));
|
assert!(html.contains("openClientExportModal"));
|
||||||
assert!(html.contains("openAddPeerModal"));
|
assert!(html.contains("openAddPeerModal"));
|
||||||
|
|
||||||
|
// Peer enrollment must submit the selected Network UUID as network_id,
|
||||||
|
// never the display name or CIDR.
|
||||||
|
assert!(html.contains(r#"value="${n.id}""#));
|
||||||
|
assert!(html.contains("${escapeHtml(n.name)} (${n.cidr})"));
|
||||||
|
assert!(html.contains("network_id: networkId"));
|
||||||
|
assert!(html.contains("isNetworkUuid"));
|
||||||
|
assert!(html.contains("selectedNetwork.id"));
|
||||||
|
assert!(!html.contains("network: network || null"));
|
||||||
|
assert!(!html.contains(r#"value="${n.name}""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
use crate::error::{Result, WireGuardError};
|
use crate::error::{Result, WireGuardError};
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use ipnet::IpNet;
|
||||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
@@ -36,6 +37,36 @@ pub struct LiveInterfaceStats {
|
|||||||
pub is_up: bool,
|
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<IpNet> {
|
||||||
|
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.
|
/// Abstract WireGuard Engine interface for kernel netlink and simulated environments.
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait WireGuardEngine: Send + Sync {
|
pub trait WireGuardEngine: Send + Sync {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub mod qr;
|
|||||||
pub use config_builder::ClientConfigBuilder;
|
pub use config_builder::ClientConfigBuilder;
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
LiveInterfaceStats, LivePeerStats, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine,
|
LiveInterfaceStats, LivePeerStats, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine,
|
||||||
WireGuardEngine,
|
WireGuardEngine, onlink_peer_address_prefixes,
|
||||||
};
|
};
|
||||||
pub use error::{Result, WireGuardError};
|
pub use error::{Result, WireGuardError};
|
||||||
pub use qr::{
|
pub use qr::{
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
//!
|
//!
|
||||||
//! No external commands (wg, ip, wg-quick, nft, sysctl) are ever executed.
|
//! 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 crate::error::{Result, WireGuardError};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
@@ -24,9 +26,13 @@ use netlink_packet_wireguard::{
|
|||||||
};
|
};
|
||||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
||||||
use rtnetlink::LinkWireguard;
|
use rtnetlink::LinkWireguard;
|
||||||
|
use rtnetlink::RouteMessageBuilder;
|
||||||
|
use rtnetlink::packet_route::AddressFamily;
|
||||||
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
||||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo};
|
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.
|
/// Linux Native WireGuard Engine using kernel RTNETLINK and Generic Netlink.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -852,6 +858,178 @@ fn parse_endpoint(s: &str) -> Result<SocketAddr> {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<IpNet> = 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<HashSet<IpNet>> {
|
||||||
|
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::<Ipv4Addr>::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::<Ipv6Addr>::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::<Ipv4Addr>::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::<Ipv6Addr>::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 ─────────────────────────────────────
|
// ── WireGuardEngine Trait Implementation ─────────────────────────────────────
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -863,6 +1041,16 @@ impl WireGuardEngine for NativeLinuxWireGuardEngine {
|
|||||||
// 2. Configure the WireGuard device (private key, listen port, peers)
|
// 2. Configure the WireGuard device (private key, listen port, peers)
|
||||||
configure_device(interface, peers).await?;
|
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!(
|
tracing::info!(
|
||||||
interface = %interface.name,
|
interface = %interface.name,
|
||||||
active_peers = peers.iter().filter(|p| p.state == PeerState::Active).count(),
|
active_peers = peers.iter().filter(|p| p.state == PeerState::Active).count(),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ The `port_range` field supports three RFC-compliant formats:
|
|||||||
NAT masquerading is governed by key-value appliance settings in SQLite:
|
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.
|
- **`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -66,8 +66,9 @@ table inet nx9_wg {
|
|||||||
|
|
||||||
Outbound NAT masquerading is dynamically scoped exclusively to managed WireGuard client subnets:
|
Outbound NAT masquerading is dynamically scoped exclusively to managed WireGuard client subnets:
|
||||||
1. **Subnet Deduplication**: Overlapping subnets are merged to prevent redundant rules.
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+8
-10
@@ -2614,8 +2614,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
FirewallSubcommands::Sync => {
|
FirewallSubcommands::Sync => {
|
||||||
let rules = store.list_firewall_rules().await?;
|
let rules = store.list_firewall_rules().await?;
|
||||||
let ifaces = store.list_interfaces().await?;
|
let subnets = nx9_wg_api::collect_managed_wg_subnets(&store).await?;
|
||||||
let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect();
|
|
||||||
let net = NativeLinuxNetworkEngine::new();
|
let net = NativeLinuxNetworkEngine::new();
|
||||||
net.sync_firewall(&rules, true, &subnets).await?;
|
net.sync_firewall(&rules, true, &subnets).await?;
|
||||||
println!("Firewall ruleset synchronized successfully.");
|
println!("Firewall ruleset synchronized successfully.");
|
||||||
@@ -2639,10 +2638,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
match args.subcommand {
|
match args.subcommand {
|
||||||
NatSubcommands::Status => {
|
NatSubcommands::Status => {
|
||||||
let ifaces = store.list_interfaces().await?;
|
let subnets: Vec<String> = nx9_wg_api::collect_managed_wg_subnets(&store)
|
||||||
let subnets: Vec<String> = ifaces
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|i| i.address_v4.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
let status = serde_json::json!({
|
let status = serde_json::json!({
|
||||||
"nat_masquerade_enabled": true,
|
"nat_masquerade_enabled": true,
|
||||||
@@ -2660,17 +2659,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!("NAT masquerade disabled in settings.");
|
println!("NAT masquerade disabled in settings.");
|
||||||
}
|
}
|
||||||
NatSubcommands::List => {
|
NatSubcommands::List => {
|
||||||
let ifaces = store.list_interfaces().await?;
|
let subnets: Vec<String> = nx9_wg_api::collect_managed_wg_subnets(&store)
|
||||||
let subnets: Vec<String> = ifaces
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|i| i.address_v4.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
print_output(&subnets, format)?;
|
print_output(&subnets, format)?;
|
||||||
}
|
}
|
||||||
NatSubcommands::Sync => {
|
NatSubcommands::Sync => {
|
||||||
let rules = store.list_firewall_rules().await?;
|
let rules = store.list_firewall_rules().await?;
|
||||||
let ifaces = store.list_interfaces().await?;
|
let subnets = nx9_wg_api::collect_managed_wg_subnets(&store).await?;
|
||||||
let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect();
|
|
||||||
let net = NativeLinuxNetworkEngine::new();
|
let net = NativeLinuxNetworkEngine::new();
|
||||||
net.sync_firewall(&rules, true, &subnets).await?;
|
net.sync_firewall(&rules, true, &subnets).await?;
|
||||||
println!("NAT masquerade rules synchronized with nftables.");
|
println!("NAT masquerade rules synchronized with nftables.");
|
||||||
|
|||||||
Reference in new issue
Block a user