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=7';
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 || [];
const masqZones = 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 ifaceRows = (ifaces) =>
ifaces.map((iface) => html`
|
<${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} />
${iface.name}
|
${(iface.ips || []).join(', ') || html`—`} |
${(iface.ipv6 || []).join(', ') || html`—`} |
${iface.mac || html`—`} |
<${Badge} text=${iface.zone || '—'} variant="secondary" /> |
`);
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade;
return html`
| ${zone} |
<${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /> |
<${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" />
|
`;
});
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`
| ${zone} |
<${Badge} text=${proto || 'tcp'} variant="info" /> |
${port} |
${fwd['to-addr'] || fwd.toaddr || '-'} |
${fwd['to-port'] || fwd.toport || '-'} |
<${ConfirmDelete}
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success="Rule removed"
refresh="firewall" />
|
`);
});
});
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 with masquerade enabled',
}),
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``,
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
rows: fwRows,
emptyText: 'No port forwarding rules',
wrapCard: false,
}),
]}),
];
},
});