/** 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, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } 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', }, 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]) => `` ).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); 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 = ''; } }, }); 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'); }), }, ], ); }); } /* ── 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` ${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)} refresh="wireguard" />` : ''} <${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'} refresh="wireguard" /> ${(pCount > 0) ? html`` : html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }} deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted" refresh="wireguard" label="Delete" />`} `; }); return html`

Access Classes

${rows}
KeyNameDescriptionSubnetPortLANPeersStatusActions
`; } /* ── 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?.config); if (guard) return guard; const wgData = state.wireguard.data; const st = wgData?.status || {}; const config = wgData?.config || {}; 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 || []; 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` <${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" removeRefresh="wireguard" 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); return html`
<${StatusDot} status=${isUp ? 'success' : 'danger'} /> ${esc(v.name || k)} (${esc(k)}) ${pCount} peer(s), port ${v.listen_port || '-'}
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'} refresh="wireguard" />`}
<${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'} refresh="wireguard" />
`; }); classSummaryCards = html`
${cards}
`; } 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'), 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: 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, }) : Empty({ text: 'No peers configured. Add a peer above.' }), renderAccessClasses(config, st), ]; }, });