/** WireGuard page — tunnel & peer management. */
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr, PendingDot, dirtySet, dirtyInfo, orphanInfo } from '/static/hoover/index.js';
/* ── 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 [];
}
}
/* ── 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: '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: (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 = csvToArr($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',
},
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]) =>
``
).join('');
}
function updateLanOptions() {
const selectEl = document.getElementById('wg-allowed-preset');
if (!selectEl) return;
const subnets = getLanSubnets();
selectEl.innerHTML =
'' +
(subnets.length
? ``
: '') +
'' +
'';
}
/* ── 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 = `
Peer Config: ${esc(peerName)}
${svgStr}
Upload a logo to overlay on the QR code.
${esc(generatedConfig)}
`;
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) => {
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 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: ep },
});
if (!resp.ok) throw resp.error || 'Failed';
const configContent = resp.data?.config;
if (!configContent) throw 'No config returned';
generatedConfig = configContent;
// Re-render modal with QR view
openModal((inn, i) => downloadConfigModal(peerName, config, state), idx);
}),
},
],
);
});
}
/* ── 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 = csvToArr($val('wg-addrs'));
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);
// No modelFetch — WS delta updates the wireguard model.
}),
},
],
);
});
}
/* ── 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',
},
postRender: (inner) => {
const sel = document.getElementById('wc-lan');
if (sel) {
sel.innerHTML = '';
}
},
});
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);
// No modelFetch — WS delta updates the wireguard model.
}),
},
],
);
});
}
/* ── 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 set = dirtySet(status);
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);
const info = dirtyInfo(set, 'access_classes.' + k);
return html`
| ${info.dirty ? PendingDot({}) : ''}${esc(k)} |
${esc(v.name || k)} |
${esc(v.description || '-')} |
${esc(v.subnet || '-')} |
${v.listen_port || '-'} |
${v.lan_access ? 'Yes' : 'No'} |
${pCount} |
<${StatusDot} status=${isUp ? 'success' : 'danger'} />
|
${!hasKeys
? html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Keys"
cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} />`
: ''}
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
cls="btn btn-sm btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} />
${(pCount > 0)
? html``
: html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }}
deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted"
label="Delete" />`}
|
`;
});
return html`
Access Classes
| Key | Name | Description | Subnet | Port | LAN | Peers | Status | Actions |
${rows}
`;
}
/* ── Main Page ───────────────────────────────────────────────── */
export default definePage({
title: 'WireGuard - Vacuum Wall',
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?.config);
if (guard) return guard;
const wgData = state.wireguard.data;
const st = wgData?.status || {};
const config = wgData?.config || {};
const set = dirtySet(st);
const isUp = st.up || false;
const listenPort = config.interface?.listen_port || '-';
const serverEndpoint = config.interface?.server_endpoint || '';
// Build merged peer rows: configured peers + live status
const configuredPeers = wgData?.peers || [];
// A removed peer leaves a `peers.` pending path with no live row
// to attach a per-row marker to; surface it on the table itself.
const peersOrphan = orphanInfo(set, 'peers', configuredPeers.map(p => 'peers.' + p.name));
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 info = dirtyInfo(set, 'peers.' + p.name);
const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined;
return html`
${info.dirty ? PendingDot({}) : ''}
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
${esc(p.name || 'unnamed')}
${p.description ? html` ${esc(p.description)}` : ''}
|
<${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /> |
${esc((p.allowed_ips || []).join(', ') || '-')} |
${esc(p.endpoint || '-')} |
${classInfo
? html`<${Badge} text=${esc(classInfo.name)} cls="bg-info text-white" />`
: html`<${Badge} text="Unassigned" cls="bg-secondary text-white" />`}
|
${esc(sp?.latest_handshake || 'Never')} |
Recv: ${esc(sp?.transfer_received || '0')}
Sent: ${esc(sp?.transfer_sent || '0')}
|
<${ActionCell}
editLabel="Config" editClick=${() => downloadConfigModal(p.name, config, state)}
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
removeMessage=${'Remove peer ' + p.name + '?'}
removeSuccess="Peer removed"
deleteKey=${p.name} />
`;
});
// 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);
const info = dirtyInfo(set, 'access_classes.' + k);
return html`
Subnet: ${esc(v.subnet || '-')}
LAN: ${v.lan_access ? 'Yes' : 'No'}
Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} />`}
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
cls="btn btn-xs btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
successMsg=${isUp ? 'Stopped' : 'Started'} />
`;
});
classSummaryCards = html`${cards}
`;
}
const ifaceInfo = dirtyInfo(set, 'interface');
const actions = ActionGroup(
html``,
html``,
ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
}),
ApplyConfirm({
pending: st.pending_changes || false,
successMsg: 'WireGuard applied',
}),
);
const subtitleParts = ['Tunnel: ' + (isUp ? 'up' : 'down'), 'Listen: ' + listenPort];
if (serverEndpoint) subtitleParts.push('Endpoint: ' + serverEndpoint);
return [
PageHeader({
title: 'WireGuard',
subtitle: subtitleParts.join(' | '),
actions,
}),
ServiceStatus({ state: isUp ? 'up' : 'down' }),
classSummaryCards,
peerRows.length
? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows,
cls: peersOrphan.class || undefined,
title: peersOrphan.title || undefined,
})
: Empty({ text: 'No peers configured. Add a peer above.' }),
renderAccessClasses(config, st),
];
},
});