import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; function addFwdModal(zones, state) { openModal((inner, idx) => { formModal(inner, 'Add Port Forward', [ { label: 'Zone', id: 'fwd-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) }, { 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' }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { const 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, }; if (!body.zone || !body.port || !body.proto) { toast('Zone, port, and proto are required', 'error'); return; } const r = await apiFetch('/api/firewall/forward-port', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (r.ok) { toast('Forward rule added', 'success'); closeModal(idx); await load(state); } else { toast(r.error || 'Failed', 'error'); } }, }, ], ); }); } async function load(state) { try { const r = await apiFetch('/api/firewall/config'); if (r.ok) state.config = r.data || {}; const zr = await apiFetch('/api/firewall/zones'); if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {}); } catch (e) { state.error = String(e); } state.loading = false; } export default definePage({ init() { return { config: {}, activeZones: [], loading: true, error: null }; }, subscribe: ['firewall'], load, render(state) { if (state.loading) { return [ PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }), h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card-body loading' }, 'Loading...'), ), ]; } if (state.error) { return [ PageHeader({ title: 'NAT' }), h('div', { class: 'card', key: 'error' }, h('div', { class: 'card-body error-msg' }, state.error), ), ]; } const cfg = state.config || {}; const zoneData = cfg.zones || {}; 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, h('button', { class: 'btn btn-sm btn-outline', 'on:click': async () => { const r = await apiFetch('/api/firewall/masquerade', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ zone, enable: !masq }), }); if (r.ok) { toast('Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone, 'success'); await load(state); } else { toast(r.error || 'Failed', 'error'); } }}, masq ? 'Disable' : 'Enable'), ), ); }); const fwRows = []; Object.entries(zoneData).forEach(([zone, zcfg]) => { const forwards = zcfg.forward_ports || []; forwards.forEach((fwd, i) => { fwRows.push(h('tr', { key: 'f-' + zone + '-' + i }, h('td', null, h('strong', null, zone)), h('td', null, Badge({ text: fwd['proxy-protocol'] || fwd.proto || 'tcp', variant: 'info' })), h('td', null, fwd.port), h('td', null, fwd['to-addr'] || fwd.toaddr || '-'), h('td', null, fwd['to-port'] || fwd.toport || '-'), h('td', null, h('button', { class: 'btn btn-sm btn-danger', 'on:click': async () => { const port = fwd.port, proto = fwd['proxy-protocol'] || fwd.proto; if (!confirm('Remove forward ' + zone + ':' + port + '/' + proto + '?')) return; const r = await apiFetch('/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), { method: 'DELETE' }); if (r.ok) { toast('Rule removed', 'success'); await load(state); } else { toast(r.error || 'Failed', 'error'); } }}, 'Remove'), ), )); }); }); return [ PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }), h('h3', { class: 'section-title' }, 'Masquerade'), h('div', { class: 'card' }, h('table', { class: 'table' }, h('thead', null, h('tr', null, h('th', null, 'Zone'), h('th', null, 'Status'), h('th', { style: 'width:100px;' }, 'Action'), ), ), h('tbody', null, ...(masqRows.length ? masqRows : [ h('tr', null, h('td', { colspan: 3, class: 'text-muted' }, 'No zones')), ]), ), ), ), h('h3', { class: 'section-title' }, 'Port Forwarding'), h('div', { class: 'card' }, h('div', { style: 'padding:0.75rem;', class: 'flex' }, h('button', { class: 'btn btn-sm btn-primary', 'on:click': () => addFwdModal(state.activeZones, state) }, 'Add Forward'), ), h('table', { class: 'table' }, h('thead', null, h('tr', null, h('th', null, 'Zone'), h('th', null, 'Proto'), h('th', null, 'Port'), h('th', null, 'To Addr'), h('th', null, 'To Port'), h('th', { style: 'width:80px;' }, 'Action'), ), ), h('tbody', null, ...(fwRows.length ? fwRows : [ h('tr', null, h('td', { colspan: 6, class: 'text-muted text-sm' }, 'No port forwarding rules'), ), ]), ), ), ), ]; }, });