Files
vacuum-wall/webui/static/pages/nat.js
T
2026-06-17 03:41:08 +00:00

195 lines
9.0 KiB
JavaScript

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, abortController, entry) {
if (Object.keys(state.config || {}).length) state.refreshing = true;
else state.loading = true;
try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const r = await apiFetch('/api/firewall/config', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (r.ok) state.config = r.data || {};
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {});
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
}
export default definePage({
init() {
return { config: {}, activeZones: [], loading: true, refreshing: false, error: null };
},
subscribe: ['firewall'],
load,
render(state) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : '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'),
),
]),
),
),
),
];
},
});