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
+125 -13
View File
@@ -210,12 +210,31 @@ export function formModal(inner, title, fields, actions) {
/**
* Factory that returns a function to open a multi-select modal.
*
* Renders a scrollable, filtered checkbox list (not a native
* `<select multiple>`). Options are sorted; each row may carry optional
* description text. A live search box filters rows in place (typing does
* not re-render the modal, so input focus is preserved), a counter shows
* "N of M selected", and Select all / Clear act on the currently visible
* rows.
*
* When `props.common` is a non-empty array an advanced toggle appears:
* cleared (the default) the list shows only common options plus anything
* currently selected; checked it shows every option. Deselecting a
* non-common option while the advanced toggle is cleared hides its row
* again.
*
* Selection, the search query, and the advanced flag live in a closure per
* open call, so `refreshModals()` re-renders (e.g. the processing spinner)
* re-apply the current state instead of losing it.
*
* @param {object} props
* @param {string} props.title - Modal title
* @param {string} props.url - API POST URL
* @param {string[]} props.options - All selectable options
* @param {string[]} props.selected - Currently selected values
* @param {string} props.fieldKey - JSON key for the field
* @param {object} [props.descriptions] - Option value → description text
* @param {string[]} [props.common] - When set, enables the advanced toggle
* @param {string} [props.successMsg] - Success toast message
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {function} [props.confirm] - (body) => string|null; confirm gate, see apiSubmit
@@ -223,24 +242,20 @@ export function formModal(inner, title, fields, actions) {
*/
export function MultiSelectModal(props = {}) {
return () => {
const selectId = 'ms-' + props.fieldKey;
const allOpts = [...new Set(props.options || [])].sort();
const sel = new Set(props.selected || []);
const hasAdv = Array.isArray(props.common) && props.common.length > 0;
const descs = props.descriptions || {};
let query = '';
let advanced = false;
openModal((inner) => {
formModal(inner, props.title,
[{
label: props.fieldKey,
id: selectId,
tag: 'select',
multiple: true,
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
}],
formModal(inner, props.title, [],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url: props.url,
body: () => ({
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
.map(o => o.value),
}),
body: () => ({ [props.fieldKey]: [...sel].sort() }),
successMsg: props.successMsg || 'Updated',
refresh: props.refresh,
confirm: props.confirm,
@@ -248,6 +263,103 @@ export function MultiSelectModal(props = {}) {
}),
],
);
const body = inner.querySelector('.modal-body');
const showSearch = allOpts.length > 8;
const rowsHtml = allOpts.map((o) => {
const d = descs[o];
return '<label class="ms-row" data-ms-value="' + att_esc(o) + '">'
+ '<input type="checkbox" class="ms-check"' + (sel.has(o) ? ' checked' : '') + '>'
+ '<span class="ms-name">' + esc(o) + '</span>'
+ (d ? '<span class="ms-desc">' + esc(d) + '</span>' : '')
+ '</label>';
}).join('');
body.innerHTML = '<div class="ms-picker">'
+ (showSearch
? '<div class="ms-toolbar">'
+ '<input type="search" class="ms-search" id="ms-search-' + att_esc(props.fieldKey) + '"'
+ ' placeholder="Filter…" value="' + att_esc(query) + '">'
+ '<span class="ms-count"></span>'
+ '</div>' : '<div class="ms-toolbar"><span class="ms-count"></span></div>')
+ '<div class="ms-subbar">'
+ '<button type="button" class="btn-link ms-selall">Select all</button>'
+ '<button type="button" class="btn-link ms-clear">Clear</button>'
+ (hasAdv
? '<label class="ms-advanced"><input type="checkbox" class="ms-adv-check"'
+ (advanced ? ' checked' : '') + '>Show all ' + allOpts.length + ' options</label>'
: '')
+ '</div>'
+ '<div class="ms-list">' + rowsHtml + '</div>'
+ '<div class="ms-empty" hidden></div>'
+ '</div>';
const rows = [...body.querySelectorAll('.ms-row')];
const countEl = body.querySelector('.ms-count');
const searchEl = body.querySelector('.ms-search');
const advEl = body.querySelector('.ms-adv-check');
const emptyEl = body.querySelector('.ms-empty');
const isVisible = (o) => {
if (query && !o.toLowerCase().includes(query)) return false;
if (!hasAdv || advanced) return true;
return sel.has(o) || props.common.includes(o);
};
const apply = () => {
let visibleCount = 0;
for (const row of rows) {
const show = isVisible(row.dataset.msValue);
row.hidden = !show;
if (show) visibleCount++;
}
if (emptyEl) {
emptyEl.hidden = visibleCount > 0;
emptyEl.textContent = allOpts.length === 0
? 'Nothing to select.'
: 'No matches for "' + (query || '') + '".';
}
countEl.textContent = sel.size + ' of ' + allOpts.length + ' selected';
};
const syncRowChecks = () => {
for (const row of rows) {
row.querySelector('.ms-check').checked = sel.has(row.dataset.msValue);
}
};
if (searchEl) {
searchEl.addEventListener('input', () => {
query = searchEl.value.trim().toLowerCase();
apply();
});
}
if (advEl) {
advEl.addEventListener('change', () => {
advanced = advEl.checked;
apply();
});
}
for (const row of rows) {
const cb = row.querySelector('.ms-check');
cb.addEventListener('change', () => {
const o = row.dataset.msValue;
if (cb.checked) sel.add(o);
else sel.delete(o);
apply();
});
}
body.querySelector('.ms-selall').addEventListener('click', () => {
for (const row of rows) if (!row.hidden) sel.add(row.dataset.msValue);
syncRowChecks();
apply();
});
body.querySelector('.ms-clear').addEventListener('click', () => {
for (const row of rows) if (!row.hidden) sel.delete(row.dataset.msValue);
syncRowChecks();
apply();
});
apply();
});
};
}
+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.' }),
+123
View File
@@ -473,7 +473,130 @@ body {
font-size: 1.1rem;
}
.btn-link {
background: none;
border: none;
padding: 0;
font-size: 0.8rem;
font-family: inherit;
color: var(--accent);
cursor: pointer;
}
.btn-link:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* Multi-select picker (MultiSelectModal) */
.ms-toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.ms-search {
flex: 1;
padding: 0.45rem 0.7rem;
font-size: 0.9rem;
font-family: inherit;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
transition: border-color 0.2s;
}
.ms-search:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 180, 216, 0.15);
}
.ms-count {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.ms-subbar {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 0.5rem;
}
.ms-advanced {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.ms-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-input);
}
.ms-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0.7rem;
font-size: 0.9rem;
cursor: pointer;
}
/* Author display rules beat the [hidden] UA rule unless re-declared. */
.ms-row[hidden] {
display: none;
}
.ms-row:not(:last-child) {
border-bottom: 1px solid var(--border);
}
.ms-row:hover {
background: var(--bg-secondary);
}
.ms-check {
width: 14px;
height: 14px;
margin: 0;
flex-shrink: 0;
accent-color: var(--accent);
align-self: center;
}
.ms-name {
font-weight: 500;
white-space: nowrap;
}
.ms-desc {
flex: 1;
color: var(--text-muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-empty {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
/* Toggle Switch */
.toggle-switch {