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]