firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync

Post-DHCP-incident hardening per HARDEN.md.

- apply guard: refuse (ConflictError, `force` overrides) when a
  network-managed interface would end up in no zone; absent
  `interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
  + advisory in /api/status/pending; zones.js banner + interfaces-picker
  last-zone confirm
- target drift (Option A): absent or default-normalizing target is
  unmanaged: not diffed, never re-set by apply; create_zone runs
  --new-zone first and sets non-default targets only; importer omits
  the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
  deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
  ({timestamp, default_zone, zones, config}); drop the empty post-apply
  skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
  teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
  (get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
  867 passing
This commit is contained in:
2026-08-28 23:38:21 +00:00
parent 55309cfd86
commit ac52918df5
25 changed files with 1677 additions and 168 deletions
+55 -5
View File
@@ -1,5 +1,14 @@
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js';
// Services shown by default in the service picker. Everything else is only
// visible with the "Show all options" toggle (or while it is already
// selected on the zone).
const COMMON_SERVICES = [
'amqp', 'cron', 'docker', 'ftp', 'ftps', 'http', 'https', 'irc', 'ldap',
'mysql', 'nfs', 'ntp', 'postgresql', 'radius', 'rsync', 'sip', 'smtp',
'smtps', 'snmp', 'ssh', 'telnet', 'vnc', 'xmpp',
];
const addZone = QuickModal({
title: 'Add Zone',
fields: [
@@ -24,12 +33,12 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const zones = Object.keys(state.firewall.data?.zones || {});
const activeZones = state.firewall.data?.active_zones || {};
// Live zone data (parsed `--list-all-zones`): carries interfaces,
// services, target, and masquerade for every defined zone.
const liveZones = state.firewall.data?.zones || {};
const zoneDetails = {};
for (const name of zones) {
const activeIfaces = activeZones[name];
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
for (const name of Object.keys(liveZones)) {
zoneDetails[name] = liveZones[name] || { interfaces: [] };
}
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
@@ -66,12 +75,34 @@ export default definePage({
selected: ifacesArr,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
confirm: (b) => {
const next = (b && b.interfaces) || [];
const coveredElsewhere = new Set();
for (const [zn, zd] of Object.entries(liveZones)) {
if (zn === name) continue;
const other = zd && Array.isArray(zd.interfaces)
? zd.interfaces : [];
for (const i of other) coveredElsewhere.add(i);
}
const dropped = ifacesArr.filter(
i => !next.includes(i) && !coveredElsewhere.has(i));
if (dropped.length) {
return 'Removing ' + dropped.join(', ') + ' from this ' +
'zone leaves it in no firewall zone. Clients on ' +
'that segment will lose all connectivity, ' +
'including DHCP, until the interface is added ' +
'to another zone.\n\nRemove it anyway?';
}
return null;
},
})()}>Interfaces</button>
<button class="btn btn-sm btn-outline"
onClick=${() => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.firewall.data?.available_services || [],
descriptions: state.firewall.data?.service_descriptions || {},
common: COMMON_SERVICES,
selected: svcsArr,
fieldKey: 'services',
successMsg: 'Services updated',
@@ -97,6 +128,24 @@ export default definePage({
</div>`;
});
const uncovered = Array.isArray(state.firewall.data?.uncovered_interfaces)
? state.firewall.data.uncovered_interfaces
: [];
const uncoveredBanner = uncovered.length ? html`<div class="card"
style="border-left:3px solid var(--danger)">
<div class="card-body">
<div class="text-danger" style="font-weight:600;margin-bottom:8px">
Uncovered interfaces
</div>
<div class="text-muted text-sm" style="margin-bottom:10px">
These interfaces are not assigned to any firewall zone, so clients
on these segments lose all connectivity, including DHCP. Add each
interface to a zone to restore access.
</div>
<div>${uncovered.map(i => html`<${Badge} text=${esc(i)} variant="danger" />`)}</div>
</div>
</div>` : null;
return [
PageHeader({
title: 'Zones',
@@ -104,6 +153,7 @@ export default definePage({
actions: html`<button class="btn btn-primary"
onClick=${() => addZone()}>Add Zone</button>`,
}),
uncoveredBanner,
zoneCards.length
? html`<div class="card-grid">${zoneCards}</div>`
: Empty({ text: 'No zones configured. Add a zone to get started.' }),