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:
+7
-2
@@ -1962,7 +1962,7 @@ Aggregate pending changes across all subsystems. Useful for the dashboard to sho
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `firewall` | `object` | `{ needs_apply, change_count, changes: [{summary, detail}] }` |
|
||||
| `firewall` | `object` | `{ needs_apply, change_count, changes: [{summary, detail}], uncovered_interfaces: [string], coverage_warnings: [string] }`. `uncovered_interfaces` lists network-config interfaces (excluding `lo`/`wg*`) that are in no live firewalld zone, and `coverage_warnings` carries the matching advisory text. Both are advisory only — they are **not** counted in `needs_apply`, `change_count`, or `total_changes` |
|
||||
| `dnsmasq` / `nginx` / `wireguard` / `networkd` | `object` | `{ pending_changes, summary, changes: [{summary, detail}] }` |
|
||||
| `total_changes` | `number` | Total count of pending changes across all subsystems |
|
||||
|
||||
@@ -1981,7 +1981,12 @@ Apply pending changes for all subsystems in dependency order.
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `applied` | `[string, ...]` | List of subsystems that were applied |
|
||||
| `errors` | `[object, ...]` | Any errors encountered during apply |
|
||||
| `errors` | `object` | Map of subsystem label → error message |
|
||||
|
||||
The firewall apply runs with `force=false`, so if a firewall interface
|
||||
would be left without zone coverage (the coverage guard), a
|
||||
`ConflictError` surfaces in `errors` under `"Firewall"` while the other
|
||||
subsystems proceed — the desired no-silent-apply behavior.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
|
||||
|
||||
| Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence |
|
||||
|---|---|---|---|---|
|
||||
| firewalld | `config/firewall/config.json` | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `config.json` is the declarative source of truth. `rules.json` serves as an automated backup snapshot. |
|
||||
| firewalld | `config/firewall/config.json` | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `config.json` is the declarative source of truth. `rules.json` is a pre-apply recovery snapshot (`{timestamp, default_zone, zones, config}`) written before every apply; `zones` is the permanent firewalld zone view. |
|
||||
| dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
|
||||
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
|
||||
| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
|
||||
@@ -295,7 +295,7 @@ data/
|
||||
├── dnsmasq/
|
||||
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
||||
├── firewall/
|
||||
│ └── rules.json # Auto-generated firewall rule state backup
|
||||
│ └── rules.json # Pre-apply firewall recovery snapshot
|
||||
├── acme/ # ACME certificate files (acme.sh home)
|
||||
├── logs/
|
||||
│ └── vacuum-wall.log # Application log file
|
||||
|
||||
+5
-3
@@ -482,7 +482,7 @@ If the interface is already up, `wg-quick up` will reconfigure it in place witho
|
||||
|
||||
**File**: `config/firewall/config.json`
|
||||
|
||||
This file defines the declarative firewalld zone configuration. The application compares it against the live firewalld state via `_compute_pending_changes()` and applies incremental changes. Runtime state backups are stored in `data/firewall/rules.json`.
|
||||
This file defines the declarative firewalld zone configuration. The application compares it against the live firewalld state via `_compute_pending_changes()` and applies incremental changes. Before every apply a **pre-apply recovery snapshot** is written to `data/firewall/rules.json`: `{timestamp, default_zone, zones, config}` where `zones` is the permanent firewalld zone view (`--list-all-zones --permanent`) and `config` is the declarative config at apply time. The permanent view is what is reproducible for manual recovery.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -519,7 +519,7 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
|
||||
|---|---|---|---|
|
||||
| `interfaces` | array | No | Network interfaces assigned to this zone. Computed against live state to detect pending changes. Default: `[]`. |
|
||||
| `services` | array | No | Firewalld services to allow in this zone (e.g., `ssh`, `https`, `dns`, `dhcp`). Default: `[]`. |
|
||||
| `target` | string | No | Zone target policy. One of: `DEFAULT`, `ACCEPT`, `DROP`, `REJECT`. The code maps these to firewalld's canonical target values (`default`, `ACCEPT`, `DROP`, `REJECT`). Default: `DEFAULT`. |
|
||||
| `target` | string | No | Zone target policy. `ACCEPT`, `DROP`, or `REJECT` is fully managed. When the key is **omitted** (the canonical "unmanaged" notation) or normalizes to `default` (e.g. a legacy explicit `"DEFAULT"`), the live value is **preserved** — it is not diffed and never re-set by apply (firewalld cannot set `default` back). |
|
||||
| `masquerade` | boolean | No | Enable IP masquerading (NAT) for this zone. Default: `false`. |
|
||||
| `forward_ports` | array | No | Port forwarding rules. Each entry has an auto-generated `id` field and the standard firewalld forward-port fields. Default: `[]`. |
|
||||
| `forward_ports[].id` | string | No | Auto-generated unique identifier for the port forwarding rule. Not user-settable. |
|
||||
@@ -532,12 +532,14 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
|
||||
|
||||
### Applying Firewall Configuration
|
||||
|
||||
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`.
|
||||
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. Likewise the `interfaces` entry is only reported for zones whose config explicitly carries the key.
|
||||
|
||||
Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift.
|
||||
|
||||
**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
|
||||
|
||||
**Interface-coverage guard.** `POST /firewall/config/apply` also refuses (before any mutation) if applying would leave a network-subsystem-managed interface in **no** firewall zone — traffic (and DHCP) on that segment would be dropped. Guarded interfaces are the keys of the network config's `interfaces`, excluding `lo` and `wg*` (vpn zones are managed by the WireGuard sync and `lo` is normally zoneless). Zones whose config omits the `interfaces` key are left hands-off, so their current live interfaces count as coverage, as do the live interfaces of zones that are live but absent from the config. Send `"force": true` to override. The condition is always surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`) and as an advisory in `GET /api/status/pending`.
|
||||
|
||||
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel.
|
||||
|
||||
## Networkd (IP Configuration)
|
||||
|
||||
@@ -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()`
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── nginx/sites-enabled/ # Generated server blocks
|
||||
│ ├── dnsmasq/fragments/ # User config fragments
|
||||
│ ├── acme/ # ACME certificates
|
||||
│ ├── firewall/ # Firewall rule backup
|
||||
│ ├── firewall/ # Pre-apply recovery snapshot
|
||||
│ ├── logs/ # Application logs
|
||||
│ ├── networkd/ # Generated 50-<name>.network files
|
||||
│ └── wireguard/ # Generated WireGuard configs
|
||||
|
||||
+9
-1
@@ -35,7 +35,7 @@ return annotation references them.
|
||||
|
||||
| Subsystem | Poll | Volatile fields | Top-level keys |
|
||||
|---|---|---|---|
|
||||
| `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `interfaces`, `available_services`, `zones`, `rich_rules`, `pending`, `timestamp` |
|
||||
| `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `interfaces`, `available_services`, `service_descriptions`, `uncovered_interfaces`, `zones`, `rich_rules`, `pending`, `timestamp` |
|
||||
| `dnsmasq` | 10s | *(none)* | `config`, `status`, `leases`, `timestamp` |
|
||||
| `nginx` | 60s | *(none)* | `config`, `domains`, `status`, `timestamp` |
|
||||
| `acme` | 300s | *(none)* | `certs`, `email`, `account`, `timestamp` |
|
||||
@@ -58,6 +58,14 @@ Top-level `FirewallState`:
|
||||
{name, mac, state, mtu, ips, ipv6, zone}
|
||||
],
|
||||
available_services: [str], // firewall-cmd --get-services
|
||||
service_descriptions: {svc: str}, // one-line description from the
|
||||
// firewalld service XML definitions
|
||||
// (lib/firewall.py get_service_descriptions,
|
||||
// cached per process)
|
||||
uncovered_interfaces: [str], // network-config interfaces (excluding
|
||||
// lo/wg*) not in any live zone —
|
||||
// advisory coverage warning; empty =
|
||||
// fully covered; NOT counted in pending
|
||||
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
|
||||
// may carry "sources", "ports",
|
||||
// "protocols", "forward-ports", "ics",
|
||||
|
||||
Reference in New Issue
Block a user