fix: route peer allocation through selected network
This commit is contained in:
1 parent
32a325234a
commit
5599e1b5c8
3 files changed
+170
-6
No files matched your search
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in new issue
Block a user