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();
});
};
}