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:
2026-07-20 03:57:16 +00:00
parent dadabd7954
commit 04417cf05c
19 changed files with 2688 additions and 455 deletions
+31
View File
@@ -19,6 +19,7 @@ from daemon.iface import (
POST_DNSMASQ_APPLY,
POST_DNSMASQ_CONFIG,
POST_DNSMASQ_DNS_RECORD_ADD,
POST_DNSMASQ_DOMAIN,
POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD,
)
@@ -306,6 +307,36 @@ def add_dns_record_bp():
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DNS domain
# ---------------------------------------------------------------------------
@bp.route("/domain", methods=["POST"])
def set_domain_bp():
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
Args:
request: JSON body with `domain` field (string or null to clear).
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
logger.info("DNS domain updated via API: %s", body.get("domain"))
return _ok(None)
except BadRequest as exc:
logger.info("Set DNS domain rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set DNS domain: %s", exc)
return _error(str(exc), 500)
@bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
+164 -1
View File
@@ -7,15 +7,23 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CLASS_STATUS,
GET_WIREGUARD_CLASSES,
GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CLASSES,
PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY,
POST_WIREGUARD_CLASS_INIT_KEYS,
POST_WIREGUARD_CLASSES,
POST_WIREGUARD_CLASSES_UP,
POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT,
@@ -229,6 +237,8 @@ def add_peer_bp():
"allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"),
"description": body.get("description"),
"access_class": body.get("access_class"),
},
)
logger.info("WireGuard peer '%s' added via API", name)
@@ -337,3 +347,156 @@ def generate_client_bp():
except RuntimeError as exc:
logger.error("Failed to generate client config for '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["GET"])
def list_classes_bp():
"""List all access classes.
Endpoint: GET /api/wireguard/classes
"""
try:
return _ok(get(GET_WIREGUARD_CLASSES))
except RuntimeError as exc:
logger.error("Failed to list access classes: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["POST"])
def create_class_bp():
"""Create a new access class.
Endpoint: POST /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = post(POST_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Create access class rejected: %s", exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Create access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to create access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["PATCH"])
def update_class_bp():
"""Update an access class.
Endpoint: PATCH /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = patch(PATCH_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Update access class rejected: %s", exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["DELETE"])
def delete_class_bp():
"""Delete an access class.
Endpoint: DELETE /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
logger.info("Access class '%s' deleted via API", key)
return _ok(result)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("Delete access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to delete access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/up", methods=["POST"])
def class_up_bp(key):
"""Bring up a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/up
"""
try:
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought up via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring up class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/down", methods=["POST"])
def class_down_bp(key):
"""Bring down a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/down
"""
try:
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought down via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/status", methods=["GET"])
def class_status_bp(key):
"""Get status for a single access class's tunnel.
Endpoint: GET /api/wireguard/classes/<key>/status
"""
try:
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
except RuntimeError as exc:
logger.error("Failed to get class '%s' status: %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/keys/<key>", methods=["POST"])
def class_init_keys_bp(key):
"""Generate key pair for a single access class.
Endpoint: POST /api/wireguard/classes/keys/<key>
"""
try:
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
logger.info("WireGuard class '%s' keys generated via API", key)
return _ok(None)
except NotFound as exc:
logger.info("Class '%s' not found for keys: %s", key, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to generate keys for class '%s': %s", key, exc)
return _error(str(exc), 500)
+154
View File
@@ -0,0 +1,154 @@
/**
* Hoover — components/qr.js
*
* QR code SVG renderer with optional logo overlay.
* Uses qrcode-svg library for generation.
*/
import { h } from '../vdom.js?v=9';
import { esc } from '../helpers.js?v=9';
import QRCode from '../../../vendor/qrcode-svg-1.1.0.js';
/**
* Generate a QR code SVG string from text content.
*
* @param {object} props
* @param {string} props.text - Text to encode
* @param {number} [props.size=200] - QR code size in px
* @param {number} [props.margin=2] - Quiet zone margin
* @param {string} [props.ecLevel='Q'] - Error correction level (L/M/Q/H)
* @param {string} [props.logo] - Base64 data URL for center logo
* @param {number} [props.logoSize=40] - Logo size in px (when overlaying)
* @param {string} [props.color] - Foreground color (default: #000000)
* @param {string} [props.background] - Background color (default: #ffffff)
* @returns {string} SVG markup string
*/
export function qrSVG(props = {}) {
const {
text,
size = 200,
margin = 2,
ecLevel = 'Q',
logo,
logoSize = 40,
color = '#000000',
background = '#ffffff',
} = props;
if (!text) return '';
const qr = new QRCode({
content: text,
container: 'svg',
margin: margin,
padding: 0,
width: size,
height: size,
color: color,
background: background,
ecl: ecLevel,
creambo: false,
prettyprint: false,
});
let svg = qr.svg();
// Add center logo overlay if provided
if (logo) {
const halfSize = size / 2;
const halfLogo = logoSize / 2;
const ns = 'http://www.w3.org/2000/svg';
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
const rootSvg = doc.documentElement;
const viewBox = rootSvg.getAttribute('viewBox') || `0 0 ${size} ${size}`;
const newSvg = doc.createElementNS(ns, 'svg');
newSvg.setAttribute('xmlns', ns);
newSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
newSvg.setAttribute('width', size);
newSvg.setAttribute('height', size);
newSvg.setAttribute('viewBox', viewBox);
// Clone original content
const clone = rootSvg.cloneNode(true);
while (clone.firstChild) {
newSvg.appendChild(clone.firstChild);
}
// White background behind logo
const bgRect = doc.createElementNS(ns, 'rect');
bgRect.setAttribute('x', halfSize - halfLogo - 4);
bgRect.setAttribute('y', halfSize - halfLogo - 4);
bgRect.setAttribute('width', logoSize + 8);
bgRect.setAttribute('height', logoSize + 8);
bgRect.setAttribute('fill', background);
newSvg.appendChild(bgRect);
// Logo image overlay
const img = doc.createElementNS(ns, 'image');
img.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', logo);
img.setAttribute('x', halfSize - halfLogo);
img.setAttribute('y', halfSize - halfLogo);
img.setAttribute('width', logoSize);
img.setAttribute('height', logoSize);
newSvg.appendChild(img);
const serializer = new XMLSerializer();
svg = serializer.serializeToString(newSvg);
}
return svg;
}
/**
* Render a QR code as a VNode with innerHTML for the SVG.
*
* @param {object} props
* @param {string} props.text - Text to encode
* @param {number} [props.size=200] - QR code size
* @param {string} [props.logo] - Base64 data URL for logo
* @param {number} [props.logoSize=40] - Logo overlay size
* @returns {object} VNode
*/
export function QRCodeVNode(props = {}) {
const svgString = qrSVG(props);
if (!svgString) {
return h('div', {}, h('span', { class: 'text-muted' }, 'No content'));
}
return h('div', {
class: 'qr-code-container text-center',
innerHTML: svgString,
});
}
/**
* Logo upload widget — file input that produces base64 data URL.
*
* @param {object} props
* @param {string} props.id - Input element ID
* @param {function} props.onChange - Callback(logoBase64) on file select
*/
export function LogoUpload(props = {}) {
const inputId = props.id || 'qr-logo-input';
return h('div', { class: 'mb-2' }, [
h('label', { class: 'form-label' }, 'Logo (optional)'),
h('input', {
type: 'file',
id: inputId,
accept: 'image/*',
class: 'form-control',
onChange: function (e) {
const file = e.target.files?.[0];
if (!file || !props.onChange) return;
const reader = new FileReader();
reader.onload = function (ev) {
props.onChange(ev.target.result);
};
reader.readAsDataURL(file);
},
}),
]);
}
+3
View File
@@ -48,3 +48,6 @@ export { ApplyConfirm } from './components/applyconfirm.js?v=9';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=9';
/* ── UI Components: QR Code ──────────────────────────────────── */
export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js?v=9';
+28 -1
View File
@@ -172,6 +172,30 @@ export default definePage({
</td>
</tr>`);
const _setDomain = async (domain) => {
const res = await apiFetch('/api/dhcp/domain', {
method: 'POST',
body: { domain },
});
if (res.ok) {
toast('DNS domain updated', 'success');
modelFetch('dnsmasq');
} else {
toast(res.error || 'Failed to update', 'error');
}
};
const currentDomain = dnsCfg.domain || null;
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
<label style="font-weight: 600;">Search Domain</label>
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
<input id="domain-input" class="input" placeholder="example.local" />
<button class="btn btn-outline" onClick=${() => _setDomain(($val('domain-input') || '').trim())}>Set</button>
<button class="btn btn-outline" onClick=${() => _setDomain(null)}>Clear</button>
</div>
</div>`;
const tabNames = ['ranges', 'leases', 'dns', 'active'];
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
@@ -222,7 +246,10 @@ export default definePage({
state.activeTab === 'leases'
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
state.activeTab === 'dns'
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
? [
domainSection,
Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' })
] : null,
leaseTable,
];
},
+40 -21
View File
@@ -38,13 +38,16 @@ export default definePage({
const zoneData = cfg.zones || {};
const sIface = (state.firewall.data?.state || {}).interfaces || [];
const masqZones = new Set(
// With nftables, masquerade is propagated to the public zone at runtime for
// POSTROUTING to work. The config-side masquerade flag indicates which
// zones source NAT traffic (LAN / internal), not where traffic exits (WAN).
const lanZones = new Set(
Object.entries(zoneData)
.filter(([, zcfg]) => !!zcfg.masquerade)
.map(([z]) => z)
);
const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone));
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
const wanIface = sIface.filter((i) => i.zone && !lanZones.has(i.zone));
const lanIface = sIface.filter((i) => i.zone && lanZones.has(i.zone));
const ifaceRows = (ifaces) =>
ifaces.map((iface) => html`<tr key=${'ii-' + iface.name}>
@@ -60,24 +63,40 @@ export default definePage({
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
</tr>`);
// Build set of non-public zones with masquerade — determines if public is auto-propagated
const anyNonPublicMasq = Object.entries(zoneData)
.filter(([zone]) => zone !== "public")
.some(([, zcfg]) => !!zcfg.masquerade);
const masqRows = Object.entries(zoneData)
.filter(([zone]) => zone !== "public")
.map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade;
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td>
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td>
<${ActionButton}
url="/api/firewall/masquerade"
cls="btn btn-sm btn-outline"
labelOn="Disable" labelOff="Enable" condition=${masq}
body=${() => ({ zone, enable: !masq })}
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone}
refresh="firewall" />
</td>
</tr>`;
});
.map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade;
const isPublic = zone === "public";
// Public zone masquerade is auto-propagated when any non-public zone
// has it enabled (nftables backend dispatches POSTROUTING to the
// output interface's zone chain). Show it read-only with a note.
if (isPublic) {
const effective = masq || anyNonPublicMasq;
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong> <span class="text-muted">(auto)</span></td>
<td><${Badge} text=${effective ? 'Enabled' : 'Disabled'} variant=${effective ? 'success' : 'info'} /></td>
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
</tr>`;
}
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td>
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td>
<${ActionButton}
url="/api/firewall/masquerade"
cls="btn btn-sm btn-outline"
labelOn="Disable" labelOff="Enable" condition=${masq}
body=${() => ({ zone, enable: !masq })}
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone}
refresh="firewall" />
</td>
</tr>`;
});
const fwRows = [];
Object.entries(zoneData).forEach(([zone, zcfg]) => {
@@ -109,7 +128,7 @@ export default definePage({
title: 'WAN / External',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(wanIface),
emptyText: 'No WAN interfaces with masquerade enabled',
emptyText: 'No WAN interfaces',
}),
DataTableSection({
title: 'Internal / LAN',
+494 -39
View File
@@ -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),
];
},
});