import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, ZoneSelect } from '/static/hoover/index.js?v=6'; 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: (s) => ({ 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', }, reload: (s) => load(s._s), }); async function load(state, abortController, entry) { await refactorLoad(state, s => Object.keys(s.config || {}).length, async (s, sig, isAborted) => { const r = await apiFetch('/api/firewall/config', { signal: sig }); if (isAborted()) return; if (r.ok) s.config = r.data || {}; else s.error = r.error; const zr = await apiFetch('/api/firewall/zones', { signal: sig }); if (isAborted()) return; if (zr.ok) s.activeZones = Object.keys(zr.data?.active || {}); else if (!s.error) s.error = zr.error; const sr = await apiFetch('/api/firewall/state', { signal: sig }); if (isAborted()) return; if (sr.ok) s.stateData = sr.data; }, { entry, abortController }, ); } export default definePage({ init() { return { config: {}, activeZones: [], stateData: null }; }, subscribe: ['firewall'], load, render(state) { const guard = renderGuard(state, 'NAT', 'Masquerade & port forwarding', state.config); if (guard) return guard; const cfg = state.config || {}; const zoneData = cfg.zones || {}; const sIface = (state.stateData || {}).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) => h('tr', { key: 'ii-' + iface.name }, h('td', null, h('div', { class: 'd-flex align-items-center gap-2' }, StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }), h('strong', null, iface.name), ), ), h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')), h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')), h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')), h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })), ) ); const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => { const masq = !!zcfg.masquerade; return h('tr', { key: 'm-' + zone }, h('td', null, h('strong', null, zone)), h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })), h('td', null, 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, reload: () => load(state), }), ), ); }); 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(h('tr', { key: 'f-' + zone + '-' + i }, h('td', null, h('strong', null, zone)), h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })), h('td', null, port), h('td', null, fwd['to-addr'] || fwd.toaddr || '-'), h('td', null, fwd['to-port'] || fwd.toport || '-'), h('td', null, ConfirmDelete({ url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?', success: 'Rule removed', reload: () => load(state), }), ), )); }); }); 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( h('button', { class: 'btn btn-sm btn-primary', 'on:click': () => addFwd({ zones: state.activeZones, _s: state }) }, 'Add Forward'), ), Table({ columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'], rows: fwRows, emptyText: 'No port forwarding rules', wrapCard: false, }), ]}), ]; }, });