Files
vacuum-wall/webui/static/pages/nat.js
T
mteehan ca27ea5522 feature: framework-level abort handling for page lifecycle
component.js now creates an AbortController for each page mount, passing
it to load(). On unmount, the controller is aborted to cancel in-flight
requests that would otherwise mutate unmounted state.

Page load functions consistently pass the signal to apiFetch and guard
state mutations with abort checks. This eliminates the need for per-page
abortController boilerplate and prevents stale errors from appearing on
rapid navigation.

Users page now guards catch block and loading state cleanup against
aborted requests, matching passkeys.js pattern.
2026-07-28 02:52:28 +00:00

161 lines
7.6 KiB
JavaScript

import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=10';
const addFwd = QuickModal({
title: 'Add Port Forward',
fields: (d) => [
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: d.zones },
{ label: 'Port', id: 'fwd-port', type: 'number' },
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
],
submit: {
url: '/api/firewall/forward-port',
body: () => ({
zone: $val('fwd-zone'),
port: parseInt($val('fwd-port')),
proto: ($val('fwd-proto') || 'tcp').trim(),
toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
}),
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
successMsg: 'Forward rule added',
},
refresh: 'firewall',
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
};
},
render(state) {
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard;
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
const sIface = (state.firewall.data?.state || {}).interfaces || [];
// 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 && !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}>
<td>
<div class="d-flex align-items-center gap-2">
<${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} />
<strong>${iface.name}</strong>
</div>
</td>
<td>${(iface.ips || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
<td>${(iface.ipv6 || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
<td>${iface.mac || html`<span class="text-muted">—</span>`}</td>
<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)
.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]) => {
const forwards = zcfg.forward_ports || [];
forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
<td><strong>${zone}</strong></td>
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
<td>${port}</td>
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
<td>${fwd['to-port'] || fwd.toport || '-'}</td>
<td>
<${ConfirmDelete}
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
deleteKey=${zone + '/' + port + '/' + proto}
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success="Rule removed"
refresh="firewall" />
</td>
</tr>`);
});
});
return [
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
DataTableSection({
title: 'WAN / External',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(wanIface),
emptyText: 'No WAN interfaces',
}),
DataTableSection({
title: 'Internal / LAN',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(lanIface),
emptyText: 'No internal interfaces',
}),
DataTableSection({
title: 'Masquerade',
columns: ['Zone', 'Status', 'Action'],
rows: masqRows,
emptyText: 'No zones',
}),
SectionTitle({ title: 'Port Forwarding' }),
Card({ children: [
ActionGroup(
html`<button class="btn btn-sm btn-primary"
onClick=${() => addFwd({ zones: Object.keys(zoneData) })}>Add Forward</button>`,
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
rows: fwRows,
emptyText: 'No port forwarding rules',
wrapCard: false,
}),
]}),
];
},
});