diff --git a/crates/nx9-wg-api/src/routes/app_client_js.js b/crates/nx9-wg-api/src/routes/app_client_js.js
index 1e4001f..75daffc 100644
--- a/crates/nx9-wg-api/src/routes/app_client_js.js
+++ b/crates/nx9-wg-api/src/routes/app_client_js.js
@@ -630,7 +630,7 @@
@@ -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() {
const errBox = document.getElementById('peer-modal-error');
if (errBox) errBox.style.display = 'none';
const name = document.getElementById('peer-name')?.value?.trim();
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);
if (!name || !ifaceId) {
@@ -685,6 +689,22 @@
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 = {
name,
peer_type: 'road_warrior',
@@ -693,7 +713,7 @@
persistent_keepalive: 25,
dns: '1.1.1.1, 1.0.0.1',
allowed_ips: '0.0.0.0/0, ::/0',
- network: network || null
+ network_id: networkId
};
const res = await api(`/interfaces/${ifaceId}/peers`, {
@@ -1547,15 +1567,17 @@
// ── NAT & Masquerade ────────────────────────────────────────────────────────
async function renderNatPage(container) {
- const [settings, ifaces] = await Promise.all([
+ const [settings, ifaces, networks] = await Promise.all([
api('/system/settings'),
- api('/interfaces')
+ api('/interfaces'),
+ api('/networks')
]);
const settingList = Array.isArray(settings) ? settings : [];
const natSetting = settingList.find(s => s.key === 'enable_nat');
const isNatEnabled = natSetting ? (natSetting.value === 'true' || natSetting.value === '1') : true;
const ifaceList = Array.isArray(ifaces) ? ifaces : [];
+ const networkList = Array.isArray(networks) ? networks.filter(n => n && n.enabled !== false) : [];
container.innerHTML = `
`;
diff --git a/crates/nx9-wg-api/tests/test_rest_api.rs b/crates/nx9-wg-api/tests/test_rest_api.rs
index 0a4537e..cb11aa1 100644
--- a/crates/nx9-wg-api/tests/test_rest_api.rs
+++ b/crates/nx9-wg-api/tests/test_rest_api.rs
@@ -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[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");
+}
diff --git a/crates/nx9-wg-api/tests/test_ui_endpoints.rs b/crates/nx9-wg-api/tests/test_ui_endpoints.rs
index b5a0e88..36f6f8e 100644
--- a/crates/nx9-wg-api/tests/test_ui_endpoints.rs
+++ b/crates/nx9-wg-api/tests/test_ui_endpoints.rs
@@ -123,6 +123,16 @@ async fn test_ui_spa_index_and_stylesheet_endpoints() {
assert!(html.contains("triggerCreateBackup"));
assert!(html.contains("openClientExportModal"));
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]