332d14e37d
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
158 lines
7.4 KiB
JavaScript
158 lines
7.4 KiB
JavaScript
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, 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',
|
|
},
|
|
});
|
|
|
|
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?.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`<tr key=${'ii-' + iface.name}>
|
|
<td>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} />
|
|
<strong>${iface.name}</strong>
|
|
</div>
|
|
</td>
|
|
<td>${(iface.ips || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
|
|
<td>${(iface.ipv6 || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
|
|
<td>${iface.mac || html`<span class="text-muted">—</span>`}</td>
|
|
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
|
|
</tr>`);
|
|
|
|
// 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`<tr key=${'m-' + zone}>
|
|
<td><strong>${zone}</strong> <span class="text-muted">(auto)</span></td>
|
|
<td><${Badge} text=${effective ? 'Enabled' : 'Disabled'} variant=${effective ? 'success' : 'info'} /></td>
|
|
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
|
|
</tr>`;
|
|
}
|
|
return html`<tr key=${'m-' + zone}>
|
|
<td><strong>${zone}</strong></td>
|
|
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
|
|
<td>
|
|
<${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} />
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
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`<tr key=${'f-' + zone + '-' + i}>
|
|
<td><strong>${zone}</strong></td>
|
|
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
|
|
<td>${port}</td>
|
|
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
|
|
<td>${fwd['to-port'] || fwd.toport || '-'}</td>
|
|
<td>
|
|
<${ConfirmDelete}
|
|
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
|
|
deleteKey=${zone + '/' + port + '/' + proto}
|
|
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
|
|
success="Rule removed" />
|
|
</td>
|
|
</tr>`);
|
|
});
|
|
});
|
|
|
|
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`<button class="btn btn-sm btn-primary"
|
|
onClick=${() => addFwd({ zones: Object.keys(zoneData) })}>Add Forward</button>`,
|
|
),
|
|
Table({
|
|
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
|
|
rows: fwRows,
|
|
emptyText: 'No port forwarding rules',
|
|
wrapCard: false,
|
|
}),
|
|
]}),
|
|
];
|
|
},
|
|
});
|