WireGuard access classes, firewall nftables fixes, network sync event refactor
- WireGuard: refactor to multi-interface 'access classes' model; extract config generation and helpers into lib/wireguard.py; add per-class up/down endpoints and API routes; update UI with class management pages and QR code component - Firewall: fix zone creation with --new-zone before --set-target; skip masquerade on public zone; add masquerade propagation for nftables backend so NAT works when internal zones exit via public - Network: rename sync event subsystem 'network' -> 'networkd'; always stamp config hash even when deployment fails (fixes pending-changes detection) - DHCP: add new API endpoint and update frontend page - State/Sync: update state collectors and sync buses for new subsystems - Docs: update API and config documentation for new endpoints and schemas
This commit is contained in:
+494
-39
@@ -1,48 +1,219 @@
|
||||
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction } from '/static/hoover/index.js?v=9';
|
||||
/** WireGuard page — tunnel & peer management. */
|
||||
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG } from '/static/hoover/index.js?v=9';
|
||||
|
||||
/* ── LAN detection helper ────────────────────────────────────── */
|
||||
function getLanSubnets() {
|
||||
try {
|
||||
const fw = getModel('firewall');
|
||||
if (!fw?.data) return [];
|
||||
const subnets = [];
|
||||
for (const iface of (fw.data.interfaces || [])) {
|
||||
if (!iface.zone) continue;
|
||||
const zone = fw.data.zones?.[iface.zone];
|
||||
if (!zone || zone.masquerade) continue;
|
||||
for (const ip of (iface.ips || [])) {
|
||||
if (!ip.includes('/')) continue;
|
||||
if (!subnets.includes(ip)) subnets.push(ip);
|
||||
}
|
||||
}
|
||||
return subnets;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Allowed IPs helper ──────────────────────────────────────── */
|
||||
function parseAllowedIps(value) {
|
||||
if (!value || !value.trim()) return [];
|
||||
return value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/* ── Color helpers ────────────────────────────────────────────── */
|
||||
function classColor(classKey) {
|
||||
if (!classKey) return '';
|
||||
const h = classKey.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||
return '#' + ((h * 137) % 256).toString(16).padStart(2, '0')
|
||||
+ '55' + ((h * 71) % 256).toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
/* ── Access classes helper to check keys initialized ──────── */
|
||||
function classHasKeys(cls) {
|
||||
return cls && cls.public_key && cls.public_key.length > 0;
|
||||
}
|
||||
|
||||
/* ── Add Peer Modal ──────────────────────────────────────────── */
|
||||
const addPeer = QuickModal({
|
||||
title: 'Add WireGuard Peer',
|
||||
fields: [
|
||||
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
|
||||
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
|
||||
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
|
||||
{ label: 'Description (optional)', id: 'wg-description', placeholder: 'Peer label' },
|
||||
{ label: 'Access Class *', id: 'wg-class', tag: 'select' },
|
||||
{ label: 'Allowed IPs Preset', id: 'wg-allowed-preset', tag: 'select', value: 'all' },
|
||||
{ label: 'Allowed IPs (custom)', id: 'wg-allowed', placeholder: '0.0.0.0/0' },
|
||||
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/wireguard/peers',
|
||||
body: () => ({
|
||||
name: ($val('wg-name') || '').trim(),
|
||||
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
|
||||
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
|
||||
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
|
||||
}),
|
||||
validate: (b) => !b.name ? 'Name is required' : null,
|
||||
body: (data) => {
|
||||
const preset = ($val('wg-allowed-preset') || 'all');
|
||||
let allowed_ips;
|
||||
if (preset === 'lan') {
|
||||
allowed_ips = getLanSubnets();
|
||||
} else if (preset === 'none') {
|
||||
allowed_ips = [];
|
||||
} else if (preset === 'custom') {
|
||||
allowed_ips = parseAllowedIps($val('wg-allowed'));
|
||||
} else {
|
||||
allowed_ips = ['0.0.0.0/0'];
|
||||
}
|
||||
return {
|
||||
name: ($val('wg-name') || '').trim(),
|
||||
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
|
||||
description: ($val('wg-description') || '').trim() || undefined,
|
||||
access_class: ($val('wg-class') || '').trim() || undefined,
|
||||
allowed_ips,
|
||||
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
|
||||
};
|
||||
},
|
||||
validate: (b) => !b.name ? 'Name is required' :
|
||||
!b.access_class ? 'Access Class is required' : null,
|
||||
successMsg: 'Peer added',
|
||||
},
|
||||
refresh: 'wireguard',
|
||||
postRender: (inner, data) => {
|
||||
const presetEl = document.getElementById('wg-allowed-preset');
|
||||
if (presetEl) {
|
||||
const customField = inner.querySelector('.form-group:has(#wg-allowed)');
|
||||
const toggle = () => {
|
||||
customField.style.display = presetEl.value === 'custom' ? '' : 'none';
|
||||
};
|
||||
presetEl.onchange = toggle;
|
||||
toggle();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function updateAddPeerOptions(wireguardState) {
|
||||
const classes = wireguardState?.access_classes || {};
|
||||
updateClassDropdown(classes);
|
||||
updateLanOptions();
|
||||
}
|
||||
|
||||
function updateClassDropdown(classes) {
|
||||
const selectEl = document.getElementById('wg-class');
|
||||
if (!selectEl) return;
|
||||
selectEl.innerHTML = Object.entries(classes).map(([k, v]) =>
|
||||
`<option value="${esc(k)}">${esc(v.name || k)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function updateLanOptions() {
|
||||
const selectEl = document.getElementById('wg-allowed-preset');
|
||||
if (!selectEl) return;
|
||||
const subnets = getLanSubnets();
|
||||
selectEl.innerHTML =
|
||||
'<option value="all">Route all traffic (default)</option>' +
|
||||
(subnets.length
|
||||
? `<option value="lan">Route LAN only (${esc(subnets.join(', '))})</option>`
|
||||
: '<option value="lan">Route LAN only (detecting\u2026)</option>') +
|
||||
'<option value="none">Route nothing (peer-initiated only)</option>' +
|
||||
'<option value="custom">Custom</option>';
|
||||
}
|
||||
|
||||
/* ── Download Config + QR Modal ──────────────────────────────── */
|
||||
function downloadConfigModal(peerName, config, state) {
|
||||
const peer = (config?.peers || {})[peerName];
|
||||
const ak = peer?.access_class;
|
||||
let listenPort = 51820;
|
||||
if (ak && config?.access_classes?.[ak]) {
|
||||
listenPort = config.access_classes[ak].listen_port || 51820;
|
||||
}
|
||||
const baseHost = (config?.interface?.server_endpoint || '').split(':')[0];
|
||||
const endpoint = baseHost ? baseHost + ':' + listenPort : '';
|
||||
|
||||
let generatedConfig = null;
|
||||
let logoBase64 = null;
|
||||
|
||||
const renderQrView = (innerEl, modalIdx) => {
|
||||
const svgStr = qrSVG({ text: generatedConfig, size: 256, logo: logoBase64, logoSize: 48 });
|
||||
innerEl.innerHTML = `
|
||||
<h4 class="mb-3">Peer Config: ${esc(peerName)}</h4>
|
||||
|
||||
<div class="text-center mb-3">${svgStr}</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Logo overlay (rescans QR)</label>
|
||||
<input type="file" id="qr-logo-input" accept="image/*" class="form-control mb-1">
|
||||
<small class="text-muted">Upload a logo to overlay on the QR code.</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Config Text</label>
|
||||
<pre class="code" style="max-height:200px;overflow:auto;font-size:0.8rem;">${esc(generatedConfig)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-3">
|
||||
<button class="btn btn-sm btn-outline" id="qr-restart">Regenerate</button>
|
||||
<button class="btn btn-sm btn-outline" id="qr-download">Download .conf</button>
|
||||
<button class="btn btn-sm btn-primary" id="qr-close">Close</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
innerEl.querySelector('#qr-logo-input').addEventListener('change', (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
logoBase64 = ev.target.result;
|
||||
renderQrView(innerEl, modalIdx);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
innerEl.querySelector('#qr-restart').addEventListener('click', () => {
|
||||
generatedConfig = null;
|
||||
logoBase64 = null;
|
||||
openModal((inn, i) => downloadConfigModal(peerName, config, state), modalIdx);
|
||||
});
|
||||
|
||||
innerEl.querySelector('#qr-download').addEventListener('click', () => {
|
||||
downloadBlob(new Blob([generatedConfig], { type: 'text/plain' }), peerName + '.conf');
|
||||
toast('Config downloaded', 'success');
|
||||
closeModal(modalIdx);
|
||||
});
|
||||
|
||||
innerEl.querySelector('#qr-close').addEventListener('click', () => closeModal(modalIdx));
|
||||
};
|
||||
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Download Config for ' + peerName,
|
||||
[{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }],
|
||||
if (generatedConfig) {
|
||||
renderQrView(inner, idx);
|
||||
return;
|
||||
}
|
||||
|
||||
formModal(inner, 'Peer Config: ' + peerName,
|
||||
[
|
||||
{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820', value: endpoint },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Generate', cls: 'btn-primary', action: 's',
|
||||
handler: formAction(async () => {
|
||||
const endpoint = ($val('wg-srv-endpoint') || '').trim();
|
||||
if (!endpoint) throw 'Server endpoint is required';
|
||||
const ep = ($val('wg-srv-endpoint') || '').trim();
|
||||
if (!ep) throw 'Server endpoint is required';
|
||||
const resp = await apiFetch('/api/wireguard/generate-client', {
|
||||
method: 'POST',
|
||||
body: { name: peerName, server_endpoint: endpoint },
|
||||
body: { name: peerName, server_endpoint: ep },
|
||||
});
|
||||
if (!resp.ok) throw resp.error || 'Failed';
|
||||
const configContent = resp.data?.config;
|
||||
if (!configContent) throw 'No config returned';
|
||||
downloadBlob(new Blob([configContent], { type: 'text/plain' }), peerName + '.conf');
|
||||
toast('Config downloaded', 'success');
|
||||
closeModal(idx);
|
||||
generatedConfig = configContent;
|
||||
|
||||
// Re-render modal with QR view
|
||||
openModal((inn, i) => downloadConfigModal(peerName, config, state), idx);
|
||||
}),
|
||||
},
|
||||
],
|
||||
@@ -50,37 +221,287 @@ function downloadConfigModal(peerName, config, state) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Interface Settings Modal ────────────────────────────────── */
|
||||
function settingsModal(wireguardData, state) {
|
||||
const iface = wireguardData?.config?.interface || {};
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'WireGuard Settings',
|
||||
[
|
||||
{ label: 'Listen Port', id: 'wg-port', type: 'number', value: iface.listen_port || 51820, placeholder: '51820' },
|
||||
{ label: 'Addresses (comma-separated CIDR)', id: 'wg-addrs', value: (iface.addresses || []).join(', ') || '10.137.0.1/24' },
|
||||
{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820', value: iface.server_endpoint || '' },
|
||||
{ label: 'Description', id: 'wg-desc', value: iface.description || '', placeholder: 'Optional label' },
|
||||
{ label: 'PostUp (advanced)', id: 'wg-post-up', tag: 'textarea', value: iface.post_up || '', placeholder: 'Shell command after interface up' },
|
||||
{ label: 'PostDown (advanced)', id: 'wg-post-down', tag: 'textarea', value: iface.post_down || '', placeholder: 'Shell command after interface down' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's',
|
||||
handler: formAction(async () => {
|
||||
const port = parseInt($val('wg-port'), 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) throw 'Invalid port';
|
||||
const addresses = ($val('wg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!addresses.length) throw 'At least one address required';
|
||||
const body = {
|
||||
interface: {
|
||||
listen_port: port,
|
||||
addresses,
|
||||
server_endpoint: ($val('wg-srv-endpoint') || '').trim() || undefined,
|
||||
description: ($val('wg-desc') || '').trim() || undefined,
|
||||
post_up: ($val('wg-post-up') || '').trim() || null,
|
||||
post_down: ($val('wg-post-down') || '').trim() || null,
|
||||
},
|
||||
};
|
||||
const resp = await apiFetch('/api/wireguard/config', {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw resp.error || 'Failed to save';
|
||||
toast('Settings saved', 'success');
|
||||
closeModal(idx);
|
||||
modelFetch('wireguard');
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Class Settings Modal ────────────────────────────────────── */
|
||||
const addClass = QuickModal({
|
||||
title: 'Add Access Class',
|
||||
fields: [
|
||||
{ label: 'Key', id: 'wc-key', placeholder: 'my-class' },
|
||||
{ label: 'Name', id: 'wc-name', placeholder: 'Display Name' },
|
||||
{ label: 'Description', id: 'wc-desc', placeholder: 'Optional' },
|
||||
{ label: 'Subnet (CIDR)', id: 'wc-subnet', placeholder: '10.137.2.0/24' },
|
||||
{ label: 'Listen Port', id: 'wc-port', type: 'number', placeholder: '51822' },
|
||||
{ label: 'LAN Access', id: 'wc-lan', tag: 'select', value: '0' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/wireguard/classes',
|
||||
body: () => ({
|
||||
key: ($val('wc-key') || '').trim(),
|
||||
name: ($val('wc-name') || '').trim(),
|
||||
description: ($val('wc-desc') || '').trim(),
|
||||
subnet: ($val('wc-subnet') || '').trim() || undefined,
|
||||
listen_port: parseInt($val('wc-port'), 10) || 0,
|
||||
lan_access: $val('wc-lan') === '1',
|
||||
}),
|
||||
validate: (b) => !b.key ? 'Key is required' :
|
||||
!/^[a-z0-9]+$/.test(b.key) ? 'Key must be lowercase alphanumeric' :
|
||||
!b.subnet ? 'Subnet is required' :
|
||||
!b.listen_port ? 'Listen port is required' : null,
|
||||
successMsg: 'Class added',
|
||||
},
|
||||
refresh: 'wireguard',
|
||||
postRender: (inner) => {
|
||||
const sel = document.getElementById('wc-lan');
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="1">Yes (Full LAN Access)</option><option value="0">No (Internet Only)</option>';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function editClassModal(key, cls, peerCount) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Edit Access Class: ' + key,
|
||||
[
|
||||
{ label: 'Key', id: 'wc-key', value: key, disabled: true },
|
||||
{ label: 'Name', id: 'wc-name', value: cls?.name || '' },
|
||||
{ label: 'Description', id: 'wc-desc', value: cls?.description || '' },
|
||||
{ label: 'Subnet (CIDR)', id: 'wc-subnet', value: cls?.subnet || '', placeholder: '10.137.2.0/24' },
|
||||
{ label: 'Listen Port', id: 'wc-port', type: 'number', value: cls?.listen_port || '', placeholder: '51822' },
|
||||
{ label: 'LAN Access', id: 'wc-lan', tag: 'select', value: cls?.lan_access ? '1' : '0' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's',
|
||||
handler: formAction(async () => {
|
||||
const resp = await apiFetch('/api/wireguard/classes', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
key,
|
||||
name: ($val('wc-name') || '').trim() || key,
|
||||
description: ($val('wc-desc') || '').trim(),
|
||||
subnet: ($val('wc-subnet') || '').trim() || undefined,
|
||||
listen_port: parseInt($val('wc-port'), 10) || undefined,
|
||||
lan_access: $val('wc-lan') === '1',
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw resp.error || 'Failed';
|
||||
toast('Class updated', 'success');
|
||||
closeModal(idx);
|
||||
modelFetch('wireguard');
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function initClassKeys(classKey) {
|
||||
const resp = await apiFetch('/api/wireguard/classes/keys/' + enc(classKey), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
toast(resp.error || 'Failed to generate keys', 'error');
|
||||
return;
|
||||
}
|
||||
toast('Keys generated for class "' + classKey + '"', 'success');
|
||||
modelFetch('wireguard');
|
||||
}
|
||||
|
||||
async function deleteAccessClass(key) {
|
||||
if (!confirm(`Delete access class '${key}'?`)) return;
|
||||
const resp = await apiFetch('/api/wireguard/classes', {
|
||||
method: 'DELETE',
|
||||
body: { key },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
toast(resp.error || 'Failed to delete class', 'error');
|
||||
return;
|
||||
}
|
||||
toast('Class deleted', 'success');
|
||||
modelFetch('wireguard');
|
||||
}
|
||||
|
||||
async function toggleClassTunnel(classKey, isUp) {
|
||||
const url = '/api/wireguard/classes/' + enc(classKey) + '/' + (isUp ? 'down' : 'up');
|
||||
const resp = await apiFetch(url, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
return;
|
||||
}
|
||||
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success');
|
||||
modelFetch('wireguard');
|
||||
}
|
||||
|
||||
/* ── Access Classes Section ──────────────────────────────────── */
|
||||
function renderAccessClasses(config, status) {
|
||||
const classes = config?.access_classes || {};
|
||||
const entries = Object.entries(classes);
|
||||
if (!entries.length) return null;
|
||||
|
||||
const peerCountMap = {};
|
||||
for (const [pname, pinfo] of Object.entries(config?.peers || {})) {
|
||||
const ac = pinfo?.access_class;
|
||||
if (ac) {
|
||||
peerCountMap[ac] = (peerCountMap[ac] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const classStatuses = status?.classes || {};
|
||||
|
||||
const rows = entries.map(([k, v]) => {
|
||||
const pCount = peerCountMap[k] || 0;
|
||||
const clsStatus = classStatuses[k] || { up: false };
|
||||
const isUp = clsStatus.up;
|
||||
const hasKeys = classHasKeys(v);
|
||||
const color = classColor(k);
|
||||
return html`<tr key=${k}>
|
||||
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
|
||||
<td>${esc(v.name || k)}</td>
|
||||
<td class="text-sm">${esc(v.description || '-')}</td>
|
||||
<td class="text-sm">${esc(v.subnet || '-')}</td>
|
||||
<td class="text-sm">${v.listen_port || '-'}</td>
|
||||
<td class="text-sm">${v.lan_access ? 'Yes' : 'No'}</td>
|
||||
<td>${pCount}</td>
|
||||
<td class="text-sm">
|
||||
<${StatusDot} status=${isUp ? 'success' : 'danger'} />
|
||||
</td>
|
||||
<td>
|
||||
${!hasKeys
|
||||
? html`<button class="btn btn-sm btn-warning" onClick=${() => initClassKeys(k)} title="Generate keys">Keys</button>`
|
||||
: ''}
|
||||
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
|
||||
<button class="btn btn-sm btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button>
|
||||
${(pCount > 0)
|
||||
? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
|
||||
: html`<button class="btn btn-sm btn-outline" onClick=${() => deleteAccessClass(k)}>Delete</button>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
return html`<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4 class="section-title m-0">Access Classes</h4>
|
||||
<button class="btn btn-sm btn-primary" onClick=${() => addClass()}>Add Class</button>
|
||||
</div>
|
||||
<table class="table table-sm"><thead><tr>
|
||||
<th>Key</th><th>Name</th><th>Description</th><th>Subnet</th><th>Port</th><th>LAN</th><th>Peers</th><th>Status</th><th>Actions</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Main Page ───────────────────────────────────────────────── */
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
wireguard: getModel('wireguard'),
|
||||
};
|
||||
},
|
||||
subscribe(state) {
|
||||
// Update add-peer modal options when state changes
|
||||
if (state.wireguard?.data) {
|
||||
updateAddPeerOptions(state.wireguard.data.config);
|
||||
}
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.wireguard, 'WireGuard', 'Tunnel & peer management', state.wireguard.data);
|
||||
const guard = renderGuard(state.wireguard, 'WireGuard', 'Tunnel & peer management', state.wireguard.data?.config);
|
||||
if (guard) return guard;
|
||||
|
||||
const st = state.wireguard.data?.status || {};
|
||||
const wgData = state.wireguard.data;
|
||||
const st = wgData?.status || {};
|
||||
const config = wgData?.config || {};
|
||||
const isUp = st.up || false;
|
||||
const listenPort = (state.wireguard.data?.config?.interface || {}).listen_port || '-';
|
||||
const listenPort = config.interface?.listen_port || '-';
|
||||
const serverEndpoint = config.interface?.server_endpoint || '';
|
||||
|
||||
const peerRows = (state.wireguard.data?.peers || []).map(p => {
|
||||
const hasHandshake = !!p.latest_handshake;
|
||||
return html`<tr key=${p.name}>
|
||||
// Build merged peer rows: configured peers + live status
|
||||
const configuredPeers = wgData?.peers || [];
|
||||
const statusPeersMap = {};
|
||||
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
|
||||
for (const sp of (cSt.peers || [])) {
|
||||
statusPeersMap[sp.public_key] = { ...sp, _class: cKey };
|
||||
}
|
||||
}
|
||||
// Also check legacy status peers
|
||||
for (const sp of (st.peers || [])) {
|
||||
statusPeersMap[sp.public_key] = sp;
|
||||
}
|
||||
|
||||
const peersByClass = config?.access_classes || {};
|
||||
|
||||
const peerRows = configuredPeers.map(p => {
|
||||
const sp = statusPeersMap[p.public_key];
|
||||
const isConnected = sp && !!sp.latest_handshake;
|
||||
const accessClass = p.access_class;
|
||||
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
|
||||
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
|
||||
return html`<tr key=${p.name}${borderColor}>
|
||||
<td>
|
||||
<${StatusDot} status=${hasHandshake ? 'success' : 'danger'} />
|
||||
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
|
||||
<strong>${esc(p.name || 'unnamed')}</strong>
|
||||
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
|
||||
</td>
|
||||
<td><${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /></td>
|
||||
<td class="text-sm">${esc(p.allowed_ips || '-')}</td>
|
||||
<td class="text-sm">${esc((p.allowed_ips || []).join(', ') || '-')}</td>
|
||||
<td class="text-sm">${esc(p.endpoint || '-')}</td>
|
||||
<td class="text-sm">${esc(p.latest_handshake || 'Never')}</td>
|
||||
<td class="text-sm">
|
||||
Recv: ${esc(p.transfer_recv || '0')}<br/>
|
||||
Sent: ${esc(p.transfer_sent || '0')}
|
||||
${classInfo
|
||||
? html`<${Badge} text=${esc(classInfo.name)} cls="bg-info text-white" />`
|
||||
: html`<${Badge} text="Unassigned" cls="bg-secondary text-white" />`}
|
||||
</td>
|
||||
<td class="text-sm">${esc(sp?.latest_handshake || 'Never')}</td>
|
||||
<td class="text-sm">
|
||||
Recv: ${esc(sp?.transfer_received || '0')}<br/>
|
||||
Sent: ${esc(sp?.transfer_sent || '0')}
|
||||
</td>
|
||||
<${ActionCell}
|
||||
editLabel="Config" editClick=${() => downloadConfigModal(p.name, state.wireguard.data?.config, state)}
|
||||
editLabel="Config" editClick=${() => downloadConfigModal(p.name, config, state)}
|
||||
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
|
||||
removeMessage=${'Remove peer ' + p.name + '?'}
|
||||
removeSuccess="Peer removed"
|
||||
@@ -89,35 +510,69 @@ export default definePage({
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
// Per-class summary
|
||||
const classEntries = Object.entries(config?.access_classes || {});
|
||||
let classSummaryCards = null;
|
||||
if (classEntries.length) {
|
||||
const cards = classEntries.map(([k, v]) => {
|
||||
const cSt = (st.classes || {})[k] || { up: false, peers: [] };
|
||||
const isUp = cSt.up;
|
||||
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
|
||||
const color = classColor(k);
|
||||
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
|
||||
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
|
||||
</div>
|
||||
<div class="card-body text-sm">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Subnet: ${esc(v.subnet || '-')}</span>
|
||||
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
|
||||
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<button class="btn btn-xs btn-warning" onClick=${() => initClassKeys(k)}>Generate</button>`}</span>
|
||||
</div>
|
||||
<div style="margin-top: 4px;">
|
||||
<button class="btn btn-xs btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
|
||||
}
|
||||
|
||||
const actions = ActionGroup(
|
||||
html`<button class="btn btn-primary" onClick=${() => addPeer(state)}>Add Peer</button>`,
|
||||
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
|
||||
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
|
||||
ActionButton({
|
||||
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
||||
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
|
||||
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
|
||||
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
||||
refresh: 'wireguard',
|
||||
}),
|
||||
ActionButton({
|
||||
url: '/api/wireguard/apply',
|
||||
successMsg: 'Config applied',
|
||||
label: 'Apply',
|
||||
refresh: 'wireguard',
|
||||
ApplyConfirm({
|
||||
pending: st.pending_changes || false,
|
||||
successMsg: 'WireGuard applied',
|
||||
refresh: ['wireguard', 'firewall'],
|
||||
}),
|
||||
);
|
||||
|
||||
const subtitleParts = ['Tunnel: ' + (isUp ? 'up' : 'down'), 'Listen: ' + listenPort];
|
||||
if (serverEndpoint) subtitleParts.push('Endpoint: ' + serverEndpoint);
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'WireGuard',
|
||||
subtitle: 'Tunnel: ' + (st.up ? 'up' : 'down') + ', Listen: ' + listenPort,
|
||||
subtitle: subtitleParts.join(' | '),
|
||||
actions,
|
||||
}),
|
||||
ServiceStatus({ state: st.up ? 'up' : 'down' }),
|
||||
ServiceStatus({ state: isUp ? 'up' : 'down' }),
|
||||
classSummaryCards,
|
||||
peerRows.length
|
||||
? Table({
|
||||
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'],
|
||||
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
|
||||
rows: peerRows,
|
||||
})
|
||||
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
||||
renderAccessClasses(config, st),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user