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'; 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`
<${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" /> `); // 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` ${zone} (auto) <${Badge} text=${effective ? 'Enabled' : 'Disabled'} variant=${effective ? 'success' : 'info'} /> ${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'} `; } 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)} deleteKey=${zone + '/' + port + '/' + 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', }), 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, }), ]}), ]; }, });