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
+13
View File
@@ -1292,6 +1292,12 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
Factory that returns a function to open a multi-select modal. Use as an `on:click` handler in VNode props.
The picker is a scrollable, **filtered checkbox list** (not a native
`<select multiple>`): options are sorted, a live search box filters rows in
place (shown when there are more than 8 options; typing does not re-render
the modal, so focus is preserved), a counter shows `N of M selected`, and
**Select all** / **Clear** act on the currently visible rows.
```javascript
const editIface = MultiSelectModal({
title: 'Interfaces: ' + zoneName,
@@ -1315,9 +1321,16 @@ h('button', { 'on:click': editIface }, 'Edit')
| `options` | All selectable options (`string[]`) |
| `selected` | Currently selected values (`string[]`) |
| `fieldKey` | JSON key for the submitted field |
| `descriptions` | Optional `{option: description}` map; renders a muted one-line description under each row |
| `common` | Optional `string[]`. When set, an advanced toggle appears: cleared (default) the list shows common options plus anything currently selected; checked it shows every option |
| `successMsg` | Success toast message (default: `'Updated'`) |
| `confirm` | `(body) => string \| null` confirm gate — see `apiSubmit` |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
Selection, the search query, and the advanced flag are held in a closure per
open call, so `refreshModals()` re-renders (e.g. the processing spinner)
re-apply the current state instead of losing it.
### Toast
#### `ToastContainer()`