ca27ea5522
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.
161 lines
7.6 KiB
JavaScript
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,
|
|
}),
|
|
]}),
|
|
];
|
|
},
|
|
});
|