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
+289
View File
@@ -0,0 +1,289 @@
# Hardening Plan — Firewall Zone Handling (post DHCP-incident)
Status: ready to implement (line refs re-verified against code 2026-08-28; review
corrections applied 2026-08-28; full cross-file re-verification with minor clarifications
applied 2026-08-28).
Background: LAN clients stopped receiving DHCP leases because the `internal` zone lost its
`eth1` assignment (in both live firewalld and `config/firewall/config.json` — incident-time
state; the appliance was recovered before this plan was written, so the repo's
`config.json` now shows `internal: eth1` again, per Sequencing item 4). With no zone
covering `eth1`, all inbound traffic hit the default `reject` policy — DHCP (and everything
else) from the LAN was dropped before reaching a healthy dnsmasq. Nothing flagged the
uncovered-interface condition; every apply silently reinforced it. Follow-ups below close
that blind spot and the related sharp edges. `/etc/sudoers.d/wall` stays as-is (dev privs,
user-acknowledged).
Decisions (confirmed):
- Target drift: **Option A** — absent `target` = unmanaged (not diffed, not touched by apply).
The absent key is the *single* canonical form for "unmanaged". A zone whose `target`
normalizes to `default` (e.g. the legacy explicit `"DEFAULT"`) is treated as unmanaged in
the pending diff as well — it mirrors `_config_apply`, which only ever sets
`ACCEPT/DROP/REJECT`. No backward compatibility is needed.
- Coverage guard: **ConflictError + `force`** in `_config_apply`; UI confirm in the
interfaces picker when an interface's last zone is dropped (zone *deletion* is out of
scope — `delete_zone` stays unguarded).
- Guard scope: **explicit `lo`/`wg*` filter** — guarded ifaces = network-config keys minus
`lo` and `wg*` prefixes (regardless of config contents); the coverage union also counts
live interfaces of zones absent from config (apply never touches them).
- Config cleanup is applied via **`POST /api/firewall/config` (full replace) + apply**, not
a raw JSON edit (re-stamps `_last_applied_hash`/`_last_applied_config` so cancel-all
baselines stay consistent). `PATCH` cannot be used: `deep_merge` (lib/common.py:244-256)
has no key-removal path, so the current `patch_config` endpoint cannot delete the
`target` keys (a `null` value would be written instead).
- Execution: **parallel streams** — WI-1 (A/B/C) + WI-3 are file-disjoint and run concurrently;
WI-2 → WI-4 → WI-5 stay serial (shared files: `_config_apply`, `_compute_pending_changes`,
`tests/test_firewall.py`, `docs/config.md`).
- Pin `"target": "ACCEPT"` for `internal` in config (declared intent = trusted LAN).
Extra latent bugs found during research (folded in):
- `create_zone` (daemon/handlers/firewall.py:655-663) never creates the zone at all: the
`--new-zone` step is missing and it runs `firewall-cmd --set-target=<target>` on a
nonexistent zone. For the default target it would run `--set-target=default`, which the
codebase's defensive guards treat as un-settable (the new-zone branch of
`_config_apply`, lines 188-205, already guards this; `create_zone` does not). The
current firewalld man page (verified via live docs 2026-08-28) lists `default` as an
accepted `--set-target` value for zones; the planned defensive skip of default targets
is still correct under Option A. An appliance-side check of actual behavior is optional
during live verification, not required.
- `_compute_pending_changes` (lib/firewall.py:380-381) skips entire zones with no
`interfaces` key — no field drift is ever reported for such zones.
- The explicit `"target": "DEFAULT"` entries in `config/firewall/config.json` (public,
vpn-full, work) are a one-shot install-time import artifact:
`system_import.import_firewall` (lib/system_import.py:921-932) emits a `target` key for
every imported zone with interfaces, and `_live_target_to_config` maps live `default` to
the config token `"DEFAULT"` (lib/firewall.py:353-361). No other code path writes
`target` into config. Hand/UI-edited zones (e.g. `internal`) omit the key — two
notations for the same meaning. Fixed by WI-2.
---
## WI-1 — Interface-coverage invariant + apply guard (core fix)
Goal: an interface managed by the network subsystem that ends up in *no* zone becomes a loud,
always-visible condition, and bulk-apply cannot silently produce it.
1. `daemon/handlers/firewall.py`, `_config_apply` (lines 138-403):
- Interfaces step (lines 252-279) — new semantics:
- **key absent → hands off**: skip the remove-then-add for that zone (matches the
existing "None = don't change" masquerade semantic at line 281).
- **explicit `[]` → intentional unassign-all** (the UI picker legitimately sends this).
- **Pre-mutation coverage guard**: compute post-apply coverage = union of each zone's
desired interfaces (explicit list if key present, else its *current live* set for
absent-key zones) **plus the live interfaces of zones absent from config** (apply
never touches those; without this the guard false-positives when a live-only zone
still holds an interface). Guarded interfaces = keys of
`lib.network.get_config()["interfaces"]` with `lo` and `wg*` prefixes **explicitly
filtered** (vpn zones are managed by `WgToFirewallSync`; `lo` is normally zoneless —
without the filter a networkd-managed `lo` would make every apply raise). If any
guarded interface would be uncovered → `ConflictError`
naming the interfaces and the consequence (clients lose connectivity/DHCP), overridable
with `body.force = true` (same pattern as the https/ssh lockout at lines 154-169; the
`config_apply` handler at 608-631 already receives `body`). Implementation note: the
guard needs current live interfaces for *all* zones before any mutation — take them
from one `firewall-cmd --get-active-zones` call before the zone loop (same pattern as
`set_zone_interfaces`, daemon/handlers/firewall.py:731-732); do not rely on the
per-zone `--list-all` reads that currently happen inside the mutation loop.
Place the guard alongside the https/ssh lockout check (lines 154-169), i.e. before
the pre-apply backup write — the existing lockout test
(`test_config_apply_blocks_lockout_before_backup`, tests/test_firewall.py:670-677)
pins `mock_backup.assert_not_called()`, and the new guard must respect the same
no-side-effect-on-conflict invariant. Update the `_config_apply` and `config_apply`
docstrings (both currently document only the lockout guard + `force`).
2. `set_zone_interfaces` (lines 706-791): when the new selection leaves an interface in *no*
zone, emit a prominent `logger.warning`. No block — deliberate UI action.
3. `lib/firewall.py`, `_compute_pending_changes` (lines 364-476): remove the blanket
`if not zone_cfg.get("interfaces"): continue` (line 380-381) — only gate the *interfaces*
diff on key presence; report services/target/masquerade/rules/fwd-ports drift for such
zones as today. Implementation detail: the interfaces diff itself must be gated —
`cfg_ifaces = set(zone_cfg.get("interfaces", []))` (line 383) would otherwise diff
`set()` against live for absent-key zones and emit a spurious entry; compute it only
when `"interfaces" in zone_cfg`. Behavior change: zones with `interfaces: []`
(live: `vpn`, `vpn-full`, `work`) will now report field drift on every poll, and
config zones absent from live entirely will diff against an empty zone — the
pending list may be non-empty immediately after merge.
4. `lib/state.py`, `_collect_firewall` (line 449+): compute `uncovered_interfaces`
(network-config ifaces not in any live zone) on every poll, so it is visible even with
zero pending changes. Apply the same `lo`/`wg*` filter as the WI-1.1 guard. A
network-config iface that is absent from live state entirely (down/renamed) counts as
uncovered too — not just zoneless-on-live. Add
`uncovered_interfaces: list[str]` to `schema.FirewallState` (lib/schema.py:87); the
TypedDict is shape-checked against the collector return by
`tests/test_schema_types.py::test_firewall_state` (lines 21-54), so the collector must
always include the key. Update the `FirewallState` block in `docs/state-model.md`
(lines 51-78 — the authoritative Markdown reference per lib/schema.py:3-5). Test
note: the network-config read is a file read, not `run()` — patch
`lib.network.get_config` in `test_firewall_state` (which mocks only `lib.state.run`)
so the value is deterministic. This is the detection that would have caught the
incident within 30s.
5. Surface it:
- `daemon/handlers/status.py` (lines 62-105): advisory coverage warnings in the firewall
section of `/api/status/pending` (not counted in `needs_apply`).
- `webui/static/pages/zones.js`: warning banner from
`state.firewall.data.uncovered_interfaces`.
- `docs/api.md` (line 1965): document the new advisory field in the
`/api/status/pending` firewall section (not counted in `needs_apply`/`total_changes`).
- `docs/api.md`: note that `POST /api/status/apply-all` runs the firewall apply with
`force=false` — a coverage `ConflictError` surfaces in the response `errors` dict
under "Firewall" while the other subsystems proceed (the desired no-silent-apply
behavior).
6. UI guard: the interfaces `MultiSelectModal` in zones.js gets a `confirm` hook (same
pattern as the services lockout at lines 89-99): if the selection would drop an
interface's last zone, warn that clients on that segment lose connectivity and DHCP.
Tests: `tests/test_firewall.py` — absent-key zone keeps live interfaces on apply; explicit
`[]` unassigns; conflict raised when a network iface goes uncovered; `force` bypasses;
guard ignores `lo`/`wg*` even when present in network config; live-only-zone interfaces
count as covered; pending diff now reports services drift on interface-less zones
(absent-key zones emit no spurious interfaces entry). `tests/test_api.py` /
`tests/test_status_pending.py` for the new advisory field. `tests/test_schema_types.py`
for the new `FirewallState` key. Test setup note: the guard reads
`lib.network.get_config()` — a file read, not `run()` — so every non-force `_config_apply`
test must patch `lib.network.get_config` (e.g. return `{"interfaces": {"eth0": {}}}`
matching the mocked live state). Without it, the repo's real
`config/network/config.json` (carries `eth0`+`eth1`) combined with the mocked `run`
(one return string for all calls, so `--get-active-zones` does not cover `eth1`)
raises a spurious `ConflictError`; existing tests affected include
`test_applies_existing_zone` (~560) and `test_stamps_applied_baseline` (~750).
The patch value must be consistent with the mocked live state **per test**:
`{"interfaces": {"eth0": {}}}` only works where the config driving the guard (the
`lib.firewall.get_config` mock) carries an explicit `interfaces` list
(`test_stamps_applied_baseline`, `_STAMP_TEST_CFG` with `"interfaces": ["eth0"]`) —
the explicit list covers eth0 in the post-apply union. In `test_applies_existing_zone`
that mock also carries `"interfaces": ["eth0"]` (the `{"public": {}}` mock is
`_get_config`, used only for the end-of-apply stamp at firewall.py:396), so
`{"interfaces": {"eth0": {}}}` works there too — but the single-string `run` mock
makes `--get-active-zones` parse to garbage covering neither eth0 nor eth1, so the
simplest patch is `{"interfaces": {}}` (or upgrade the `run` mock to a `side_effect`
answering `--get-active-zones` with eth0 covered). Guard ordering: the
https/ssh lockout check (lines 154-169) must run **before** the coverage guard —
`test_config_apply_blocks_lockout_before_backup` (~670) asserts the "https and ssh"
message and mocks neither `run` nor the network config, so a coverage guard evaluated
first would hit the un-mocked `run`/file read and break that test.
## WI-2 — Target-drift semantics (Option A: omit = unmanaged)
Goal: stop the trap where config omits `target` (→ implicit "default"), live says `ACCEPT`,
the pending diff flags it, and apply can never clear it (firewalld cannot set "default" back)
→ permanent fake "pending" + dead apply button.
- `lib/firewall.py`, `_compute_pending_changes`: skip the target diff when the zone config
has no explicit `target` key **or** the value normalizes to `default` (precedent: the
public-masquerade skip at lines 419-424). Defensive — it covers legacy configs still
carrying explicit `"DEFAULT"`. `_config_apply` already leaves default targets alone
(lines 207-219). Explicit `ACCEPT/DROP/REJECT` remains fully managed.
- Fix the source — `lib/system_import.py`, `import_firewall` (lines 921-932): emit the
`"target"` key only when the imported zone's live target normalizes to something other
than `default`. Today the importer emits a faithful snapshot with a `target` key for
every zone, and `_live_target_to_config` maps live `default``"DEFAULT"` (this is the
sole author of the explicit `"target": "DEFAULT"` entries; no other code path writes
`target` into config). Update `tests/test_system_import.py` (assertions at lines
538-574). Keep `_live_target_to_config` itself (lib/firewall.py:353-361; still asserted
at tests/test_firewall.py:148-149).
- `create_zone` (daemon/handlers/firewall.py:632-670): rewrite the body to mirror the full
`_config_apply` new-zone branch (lines 188-205): run `--new-zone` first (currently
missing entirely — the endpoint would create no zone at all), then `--set-target` only
when the target normalizes to something other than `default`, then `_reload()`.
- Config cleanup (on the appliance): remove the legacy `"target": "DEFAULT"` entries from
the `public`, `vpn-full`, and `work` zones (making key-absence the one canonical
"unmanaged" notation), and add `"target": "ACCEPT"` to the `internal` zone — declares the
trusted-LAN intent and makes future apply enforce it and flag any drift. Apply via
`POST /api/firewall/config` (full replace) + apply, **not** a raw JSON edit and **not**
`PATCH` (which cannot delete keys — see the Decisions note above): GET the current
config, drop the three `target` entries, add `"target": "ACCEPT"` to `internal`, POST,
then apply. Apply re-stamps `_last_applied_hash`/`_last_applied_config` so cancel-all
baselines stay consistent.
- docs/config.md: document "target omitted (or normalizes to `default`) → live value is
preserved, not diffed, and never re-set by apply".
Tests: pending-diff cases (absent target ⇒ no target entry; explicit `"DEFAULT"` ⇒ no
target entry; explicit `ACCEPT` vs live `default` ⇒ entry); `create_zone` paths
(`--new-zone` always called; `--set-target` only for non-default targets);
`import_firewall` omits `target` for default-target zones while keeping it for
`ACCEPT/DROP/REJECT`.
## WI-3 — Make the sync bus non-destructive (stale DHCP ranges)
Goal: a zone interface change must never delete user data. `FirewallToDhcpSync` currently
hard-deletes ranges the moment an interface loses zone coverage (lib/sync.py:806-822) —
exactly what ate the eth1 pool during the incident's mis-click.
- `lib/sync.py`, `FirewallToDhcpSync.on_firewall_config_saved` (lines 804-822):
- Keep the range in the dnsmasq config.
- Log a warning and emit a `SyncResult.changes` entry: "DHCP range on '<iface>' has no
firewall zone coverage — inactive until a zone covers it".
- Report `dnsmasq` in `affected_subsystems` only when the gateway auto-fill step
(lines 824-855) actually mutated config — the return at sync.py:866 becomes
`["dnsmasq"] if changed else []`.
- Update the class docstring (lines 742-748) and method docstring accordingly.
- Rationale: a range is inert only while the firewall drops the traffic; keeping it makes
zone re-assignment self-heal with zero follow-up.
Tests: `tests/test_sync.py` `TestFirewallToDhcpSync` (lines 763-920): `test_removes_stale_ranges`
becomes `test_flags_uncovered_range_without_deleting` (assert dnsmasq config untouched +
warning present). `test_keeps_global_ranges` also asserts the stale eth2 range is removed
(`len(saved_ranges) == 1`, `dnsmasq` affected, `mock_dm_save.call_args` read
unconditionally) and must be rewritten for the non-destructive semantics (both ranges kept,
no save, no affected subsystems, warning present).
## WI-4 — Make the firewall "backup" real
Goal: `data/firewall/rules.json` stores an empty skeleton before *and* after apply
(daemon/handlers/firewall.py:171-179, 385-393), so the disaster-recovery artifact promised by
docs/architecture.md:134 contains nothing.
- `_config_apply`: save a **pre-apply snapshot only**, before any mutation:
`{timestamp, default_zone, zones: _parse_all_zones_output(firewall-cmd --list-all-zones --permanent),
config: <config.json contents>}``data/firewall/rules.json` via `_save_backup`. The
permanent view is what is reproducible for manual recovery. Note: `_parse_all_zones_output`
must be added to the handler's `lib.firewall` import (daemon/handlers/firewall.py:38-43) —
it is not currently imported there.
- Remove the misleading post-apply skeleton write (lines 385-393); the apply response's
`backup` path field is unchanged. `load_backup` has no live consumers — no API changes.
- Docs: update architecture.md:134/298, config.md:485, overview.md:77 to describe the shape.
Tests: the `_save_backup` patch sites in `tests/test_firewall.py` (~lines 566, 701,
751) carry `return_value="/tmp/rules.json"` and stay valid as-is — no existing test
asserts on its arguments or call count. Optionally add one assertion that the single
pre-apply call receives the snapshot payload (`default_zone`/`zones`/`config` keys).
## WI-5 (optional, low) — Daemon shutdown noise
"Task was destroyed but it is pending" + logging-error tracebacks on daemon SIGTERM (7
occurrences since the Aug 22 restart, still happening on current code).
- `daemon/server.py` shutdown path: stop accepting new connections, give in-flight request
tasks a bounded grace period (`await server.wait_closed()` with timeout) before
`runner.cleanup()`; suppress the asyncio default exception handler during the teardown
window.
Cosmetic. Do last, or defer.
---
## Sequencing & verification
1. One branch; one commit per WI: **1 → 3 → 2 → 4 → 5**. WI-1 + WI-3 together fix the
incident class; WI-2/WI-4 are hygiene; WI-5 optional.
Execution: **Phase 1 in parallel** (file-disjoint streams — A: WI-1 backend,
`daemon/handlers/firewall.py` + `lib/firewall.py` pending-diff; B: WI-1 state surface,
`lib/state.py` + `lib/schema.py` + `daemon/handlers/status.py` + schema/status tests +
`docs/state-model.md` + `docs/api.md`;
C: WI-1 frontend, `webui/static/pages/zones.js`; D: WI-3, `lib/sync.py` +
`tests/test_sync.py`); **Phase 2 serial** — WI-2 → WI-4 → WI-5 (shared files:
`_config_apply`, `_compute_pending_changes`, `tests/test_firewall.py`, `docs/config.md`).
2. Per commit:
- `.venv/bin/ruff check lib/ webui/ daemon/ tests/`
- `.venv/bin/ruff format lib/ webui/ daemon/ tests/`
- `.venv/bin/python -m pytest tests/ -v`
- `node tests/test-*.js` for touched hoover components (zones.js itself has no node test
file — verify by loading the page in the running UI).
3. Live verification on the appliance after merge:
- Confirm `uncovered_interfaces` is empty in firewall state.
- Check the firewall pending list for the expected post-WI-1.3/WI-2 drift entries
(`interfaces: []` zones now report field drift; `internal` target pinned to
`ACCEPT`) and confirm nothing unexpected appears.
- Optional drill: create a throwaway zone and move `eth0` onto it via the API with
`force` omitted (expect ConflictError) and added (expect success + warning), then
restore. Skip if undesired — mocked tests cover the logic.
4. No live-system changes during implementation; DHCP/zone state stays as the operator left
it (internal=eth1, public=eth0, leases confirmed 01:29).
+135 -58
View File
@@ -34,10 +34,13 @@ from daemon.iface import (
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib import network
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
_normalize_target,
_now_iso,
_parse_active_zones,
_parse_all_zones_output,
_parse_zone_output,
fw_change_summary,
)
@@ -143,8 +146,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
then adding desired values. Reloads firewalld at the end.
With *force* False (default), a ``ConflictError`` is raised before any
mutation if the config would strip both https and ssh from the default
zone; pass ``force=True`` to override.
mutation in two cases; pass ``force=True`` to override either:
- the config would strip both https and ssh from the default zone
(management lockout);
- a network-subsystem-managed interface would end up with no firewall
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
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.
"""
from lib.firewall import get_config as _get_lib_config
@@ -168,15 +178,52 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
f'to the zone\'s services, or pass {{"force": true}}.'
)
full_state: dict[str, Any] = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
_save_backup(full_state)
# Coverage guard: after apply, every network-managed interface must
# belong to a zone or traffic (and DHCP) on that segment is dropped.
live_active = _parse_active_zones(
run(["firewall-cmd", "--get-active-zones"], sudo=True)
)
covered: set[str] = set()
for zn, zc in cfg_zones.items():
if "interfaces" in (zc if isinstance(zc, dict) else {}):
covered.update(zc["interfaces"])
else:
covered.update(live_active.get(zn, []))
covered.update(
iface
for zn, ifaces in live_active.items()
if zn not in cfg_zones
for iface in ifaces
)
net_cfg = network.get_config()
guarded = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
uncovered = [name for name in guarded if name not in covered]
if uncovered:
raise ConflictError(
"Refusing to apply: "
f"{', '.join(repr(n) for n in uncovered)} "
f"would have no firewall zone coverage after apply, so all "
f"traffic (including DHCP) from those segments would be "
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
)
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
# the declarative config, captured before any mutation. The permanent
# view is what is reproducible for manual recovery.
backup_path = _save_backup(
{
"timestamp": _now_iso(),
"default_zone": _default_zone(),
"zones": _parse_all_zones_output(
run(["firewall-cmd", "--list-all-zones", "--permanent"], sudo=True)
),
"config": cfg,
}
)
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
applied: list[str] = []
@@ -250,33 +297,38 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
)
# Step 3: Reconcile interfaces — same remove-then-add pattern.
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# Absent "interfaces" key = hands off (keep the zone's live
# interfaces); an explicit empty list = intentional unassign-all.
if "interfaces" in zone_cfg:
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
@@ -382,15 +434,6 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
_save_config(cfg)
_reload()
full_state = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
backup_path = _save_backup(full_state)
# Record the applied config snapshot + hash so pending-changes detection
# and cancel/revert work like the hash-based subsystems.
applied_cfg = _get_config()
@@ -612,11 +655,16 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
Args:
_request: The incoming HTTP request (unused).
_body: Optional JSON body; ``{"force": true}`` overrides the
management-lockout guard for the default zone.
management-lockout guard and the interface-coverage guard.
Returns:
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
and ``synced`` (affected subsystems).
Raises:
ConflictError: If the config would strip both https and ssh from the
default zone, or would leave a network-managed interface without
zone coverage, and ``force`` is not set.
"""
force = bool(_body and _body.get("force"))
result = _config_apply(force=force)
@@ -631,7 +679,11 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register(POST_FIREWALL_ZONES_CREATE)
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Create new zone via firewall-cmd, emit sync event, refresh state.
"""Create a new firewall zone, emit sync event, refresh state.
Runs ``--new-zone`` first (required before ``--set-target``), then sets
the target only when it normalizes to something other than ``default``
(the implicit firewalld target is never re-set), then reloads.
Args:
_request: The incoming HTTP request (unused).
@@ -652,15 +704,21 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
if zone_name in available:
raise ValueError(f"Zone '{zone_name}' already exists")
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={target}",
"--permanent",
],
sudo=True,
)
# Create the zone first; --set-target requires the zone to exist.
run(["firewall-cmd", f"--new-zone={zone_name}", "--permanent"], sudo=True)
# "default" is firewalld's implicit target and cannot be meaningfully
# re-set, so only explicit ACCEPT/DROP/REJECT targets are applied.
normalized_target = _normalize_target(target)
if normalized_target != "default":
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={normalized_target}",
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target)
sync_result = bus.emit(
@@ -707,6 +765,11 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Replace zone interfaces, reassigning interfaces from old zones.
When the new selection leaves an interface in no zone at all, a
prominent warning is logged (clients on that segment lose connectivity
and DHCP); the operation is not blocked since it is a deliberate UI
action.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``interfaces`` list.
@@ -761,6 +824,20 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
sudo=True,
)
# Flag interfaces that ended up in no zone at all — clients on those
# segments lose connectivity (including DHCP).
for iface in set(active.get(zone, [])) - set(interfaces):
if not any(
iface in az_ifaces for az, az_ifaces in active.items() if az != zone
):
logger.warning(
"Interface '%s' is now in NO firewall zone: clients on that "
"segment will lose connectivity and DHCP (zone '%s' no longer "
"covers it).",
iface,
zone,
)
_reload()
# Update config
+17
View File
@@ -65,6 +65,10 @@ def status_pending(_request: Any, _body: Any) -> dict[str, Any]:
Returns:
Dict with per-subsystem pending status and total change count.
The firewall section also carries advisory `uncovered_interfaces`
and `coverage_warnings` fields (network-config interfaces not in
any live zone); they are never counted in `needs_apply`,
`change_count`, or `total_changes`.
"""
fw = state_store.get("firewall") or {}
pending_fw = fw.get("pending", {})
@@ -78,10 +82,23 @@ def status_pending(_request: Any, _body: Any) -> dict[str, Any]:
summary = fw_change_summary(zone, ctype, c)
fw_changes.append({"summary": summary, "detail": ""})
uncovered = fw.get("uncovered_interfaces") or []
coverage_warnings = (
[
"Interfaces not in any firewall zone: "
f"{', '.join(uncovered)} — clients on those segments lose "
"connectivity and DHCP"
]
if uncovered
else []
)
fw_result = {
"needs_apply": fw_needs_apply,
"change_count": len(fw_changes),
"changes": fw_changes,
"uncovered_interfaces": uncovered,
"coverage_warnings": coverage_warnings,
}
hash_subsystems = {
+38 -4
View File
@@ -681,14 +681,48 @@ def main() -> None:
loop = asyncio.new_event_loop()
def _teardown_exception_handler(_loop, context) -> None:
# Swallow teardown noise ("Task was destroyed but it is pending",
# in-flight task exceptions on SIGTERM) instead of the default
# logging-error tracebacks.
logger.debug("Suppressed teardown exception: %s", context)
async def _shutdown() -> None:
"""Graceful shutdown: cancel poller, close runner, teardown."""
"""Graceful shutdown: stop accepting, drain in-flight work, teardown.
Bounded grace periods + a suppressed exception handler during the
teardown window avoid the "Task was destroyed but it is pending" and
logging-error tracebacks that otherwise appear on SIGTERM.
"""
logger.info("Shutting down daemon...")
_stop_polling()
# Suppress the default exception handler during teardown so that
# cancelling in-flight tasks does not spew tracebacks on SIGTERM.
prev_handler = loop.exception_handler
loop.set_exception_handler(_teardown_exception_handler)
try:
await asyncio.wait_for(runner.cleanup(), timeout=5)
except TimeoutError:
logger.warning("Runner cleanup timed out, abandoning")
# Stop accepting new connections (also waits for open sockets,
# bounded so a stuck WebSocket can't hang shutdown).
try:
await asyncio.wait_for(runner.cleanup(), timeout=5)
except TimeoutError:
logger.warning("Runner cleanup timed out, abandoning")
# Give in-flight request/WS tasks a bounded grace period to
# finish; cancel anything still pending so they are not
# "destroyed but pending" when the loop closes.
pending = [
t
for t in asyncio.all_tasks()
if t is not asyncio.current_task() and not t.done()
]
if pending:
_, still_pending = await asyncio.wait(pending, timeout=3)
for t in still_pending:
t.cancel()
if still_pending:
await asyncio.wait(still_pending, timeout=1)
finally:
loop.set_exception_handler(prev_handler)
if Path(socket_path).exists():
os.unlink(socket_path)
logger.info("vacuum-walld stopped")
+7 -2
View File
@@ -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.
---
+2 -2
View File
@@ -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
View File
@@ -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)
+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()`
+1 -1
View File
@@ -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
View File
@@ -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",
+125 -25
View File
@@ -6,9 +6,11 @@ All privileged commands are handled by daemon/handlers/firewall.py.
"""
import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from xml.etree import ElementTree
from lib.common import load_json, save_json
@@ -84,11 +86,21 @@ def _parse_interfaces(output: str) -> list[str]:
def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
"""Parse ``firewall-cmd --zone=Z --list-all`` or a zone block
from ``--list-all-zones`` output.
firewalld emits each rich rule on its own tab-indented continuation
line after an (empty) ``rich rules:`` entry; those lines carry no
colon and are collected into the ``rich-rules`` list.
"""
info: dict[str, Any] = {"name": zone}
last_key = ""
for line in output.splitlines():
line = line.strip()
if not line or ":" not in line:
if not line:
continue
if ":" not in line:
# Continuation line (rich rules); ignore anything else.
if last_key == "rich-rules":
info.setdefault("rich-rules", []).append(line)
continue
key, _, value = line.partition(":")
key = key.strip()
@@ -98,6 +110,7 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
# --zone=Z --list-all uses "rich-rules" (hyphen); normalize.
if key == "rich rules":
key = "rich-rules"
last_key = key
if not value:
if key in ("masquerade", "ics"):
@@ -176,6 +189,80 @@ def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]:
return zones
# ---------------------------------------------------------------------------
# Service catalog descriptions (firewalld service XML definitions)
# ---------------------------------------------------------------------------
# Built-ins first, /etc second, so user service definitions under
# /etc/firewalld/services override built-ins with the same name.
_SERVICE_XML_DIRS: tuple[Path, ...] = (
Path("/usr/lib/firewalld/services"),
Path("/etc/firewalld/services"),
)
_service_descriptions_cache: dict[str, str] | None = None
def _parse_service_xml(path: Path) -> str:
"""Extract the one-line text from a firewalld service XML definition.
Args:
path: Path to a ``<service>`` XML file.
Returns:
The ``<short>`` text, or ``<description>`` when ``<short>`` is
absent; empty string when neither is present or the file cannot be
read or parsed.
"""
try:
root = ElementTree.parse(path).getroot()
except (OSError, ElementTree.ParseError):
logger.warning("Could not read service definition %s", path, exc_info=True)
return ""
text = root.findtext("short") or root.findtext("description") or ""
return text.strip()
def get_service_descriptions(
dirs: Sequence[Path | str] | None = None,
) -> dict[str, str]:
"""Return a mapping of firewalld service names to one-line descriptions.
Parses the ``*.xml`` service definitions found in *dirs*. When *dirs* is
``None`` the standard system locations are used (see
``_SERVICE_XML_DIRS``) and the result is cached for the process lifetime.
When *dirs* is given the result is computed fresh and nothing is cached.
Unreadable or malformed files are skipped.
Args:
dirs: Directories containing service XML files. ``None`` selects the
default system locations.
Returns:
Dict mapping each service name (file stem) to its description text.
"""
global _service_descriptions_cache
if dirs is None and _service_descriptions_cache is not None:
return dict(_service_descriptions_cache)
search_dirs = [Path(d) for d in dirs] if dirs is not None else _SERVICE_XML_DIRS
descriptions: dict[str, str] = {}
for directory in search_dirs:
try:
entries = sorted(directory.glob("*.xml")) if directory.is_dir() else []
except OSError:
logger.warning("Skipping unreadable service directory %s", directory)
continue
for path in entries:
text = _parse_service_xml(path)
if text:
descriptions[path.stem] = text
if dirs is None:
_service_descriptions_cache = descriptions
return descriptions
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
@@ -282,6 +369,15 @@ def _compute_pending_changes(
Pure function no subprocess calls. Caller is responsible for providing
live state (typically from the daemon).
The interfaces diff is only reported for zones whose config explicitly
carries an ``interfaces`` key; zones with the key absent are hands-off
(apply keeps their live interfaces), so diffing them would advertise
changes that never happen. Likewise the target diff is only reported when
the config carries an explicit target that normalizes to something other
than ``default`` an absent key or a ``default``-normalizing value is
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
forward ports are reported for all config zones.
"""
cfg_zones = cfg.get("zones", {})
@@ -290,20 +386,19 @@ def _compute_pending_changes(
for zone_name, zone_cfg in cfg_zones.items():
live_zone = live_zones.get(zone_name, {})
if not zone_cfg.get("interfaces"):
continue
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
if "interfaces" in zone_cfg:
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
cfg_services = set(zone_cfg.get("services", []))
live_services = set(live_zone.get("services", []))
@@ -317,17 +412,21 @@ def _compute_pending_changes(
}
)
cfg_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
live_target = live_zone.get("target", "default")
if cfg_target != live_target:
changes.append(
{
"zone": zone_name,
"type": "target",
"config": cfg_target,
"live": live_target,
}
)
# Target is unmanaged when the config omits the key or the value
# normalizes to "default" (firewalld's implicit target, which apply
# never re-sets). Only an explicit ACCEPT/DROP/REJECT is diffed.
if "target" in zone_cfg and _normalize_target(zone_cfg["target"]) != "default":
cfg_target = _normalize_target(zone_cfg["target"])
live_target = live_zone.get("target", "default")
if cfg_target != live_target:
changes.append(
{
"zone": zone_name,
"type": "target",
"config": cfg_target,
"live": live_target,
}
)
# public zone masquerade is not reconciled by apply (it is driven by
# the nftables propagation step in daemon/handlers/firewall.py), so
@@ -451,6 +550,7 @@ __all__ = [
"config_pending",
"fw_change_summary",
"get_config",
"get_service_descriptions",
"load_backup",
"save_backup",
"save_config",
+6
View File
@@ -94,6 +94,10 @@ class FirewallState(TypedDict):
catch-all zone for interfaces with no explicit assignment.
interfaces: All system interfaces (see FirewallInterface).
available_services: firewalld service catalog ("--get-services").
service_descriptions: Service name to one-line description, parsed
from the firewalld service XML definitions
(``lib.firewall.get_service_descriptions``).
uncovered_interfaces: Network-config interfaces (excluding ``lo``/``wg*``) not in any live zone (advisory coverage warning, always present).
zones: All zones as runtime dicts (see FirewallZone).
rich_rules: Zone name raw firewalld rich-rule strings.
pending: config_pending() result:
@@ -107,6 +111,8 @@ class FirewallState(TypedDict):
default_zone: str
interfaces: list[FirewallInterface]
available_services: list[str]
service_descriptions: dict[str, str]
uncovered_interfaces: list[str]
zones: dict[str, FirewallZone]
rich_rules: dict[str, list[str]]
pending: dict[str, Any]
+20
View File
@@ -26,10 +26,12 @@ from lib.common import (
from lib.firewall import (
_parse_active_zones,
_parse_all_zones_output,
get_service_descriptions,
)
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import get_config as _network_get_config
from lib.network import parse_networkctl_status
logger = logging.getLogger(__name__)
@@ -538,11 +540,29 @@ def _collect_firewall() -> schema.FirewallState:
with contextlib.suppress(Exception):
pending = _config_pending(full_state)
net_cfg: dict[str, Any] = {}
with contextlib.suppress(Exception):
net_cfg = _network_get_config()
covered: set[str] = set()
for zone_ifaces in active.values():
covered.update(zone_ifaces)
for zone in zones.values():
covered.update(zone.get("interfaces", []))
uncovered_interfaces = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg") and name not in covered
]
return {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
# Parsed from the firewalld service XML definitions; cached per
# process so the 30s poll does not re-read the files.
"service_descriptions": get_service_descriptions(),
"uncovered_interfaces": uncovered_interfaces,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"config": config_data,
+24 -24
View File
@@ -742,20 +742,22 @@ class WgToFirewallSync:
class FirewallToDhcpSync:
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
Removes DHCP ranges whose interface no longer belongs to any firewall
zone. When masquerade is enabled on a zone, ensures DHCP ranges on
that zone's interfaces carry the gateway (interface IP).
Logs warnings for zones with dhcp service but no range.
Keeps DHCP ranges whose interface no longer belongs to any firewall
zone, flagging them as inactive (never deleted). When masquerade is
enabled on a zone, ensures DHCP ranges on that zone's interfaces carry
the gateway (interface IP). Logs warnings for zones with dhcp service
but no range.
"""
@classmethod
def on_firewall_config_saved(cls, event: SyncEvent) -> SyncResult | None:
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
Removes DHCP ranges whose interface no longer belongs to any firewall
zone. When masquerade is enabled on a zone, ensures DHCP ranges on
that zone's interfaces carry the gateway (interface IP). Logs warnings
for zones with dhcp service but no range.
Keeps DHCP ranges whose interface no longer belongs to any firewall
zone, flagging them as inactive (never deleted). When masquerade is
enabled on a zone, ensures DHCP ranges on that zone's interfaces
carry the gateway (interface IP). Logs warnings for zones with dhcp
service but no range.
Skips processing if event originated as a cascade from ``dnsmasq``.
@@ -763,9 +765,10 @@ class FirewallToDhcpSync:
event: Sync event with ``config_saved`` action from firewall.
Returns:
SyncResult listing dnsmasq as affected subsystem when ranges were
modified, with change descriptions. ``None`` if skipped due to
cascade guard.
SyncResult listing dnsmasq as affected subsystem only when the
gateway auto-fill step mutated config, with change descriptions
(including advisory entries for uncovered ranges). ``None`` if
skipped due to cascade guard.
"""
if event.payload.get("_cascade") == "dnsmasq":
return None
@@ -803,23 +806,20 @@ class FirewallToDhcpSync:
changes: list[str] = []
# Auto-remove stale DHCP ranges (interface no longer in any zone)
# Flag uncovered DHCP ranges (interface no longer in any zone)
# kept in config, not deleted
stale_ifaces = range_ifaces - all_zone_ifaces
if stale_ifaces:
ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
remaining = [
r
for r in ranges
if not r.get("interface") or r["interface"] not in stale_ifaces
]
dnsmasq_cfg.setdefault("dhcp", {})["ranges"] = remaining
_save_dnsmasq_cfg(dnsmasq_cfg)
for iface in sorted(stale_ifaces):
logger.info(
"Removed stale DHCP range on '%s' (no firewall zone)",
logger.warning(
"DHCP range on '%s' has no firewall zone coverage — "
"inactive until a zone covers it",
iface,
)
changes.append(f"Removed stale DHCP range on interface '{iface}'")
changes.append(
f"DHCP range on '{iface}' has no firewall zone coverage — "
f"inactive until a zone covers it"
)
# When masquerade is enabled on a zone, ensure DHCP ranges have gateway
changed = False
@@ -863,7 +863,7 @@ class FirewallToDhcpSync:
changes.append(f"Zone has dhcp service on '{iface}' but no DHCP range")
return SyncResult(
affected_subsystems=["dnsmasq"] if changed or stale_ifaces else [],
affected_subsystems=["dnsmasq"] if changed else [],
changes=changes,
)
except Exception:
+12 -6
View File
@@ -918,18 +918,24 @@ def import_firewall() -> bool:
logger.warning("Failed to parse firewall zones", exc_info=True)
return False
zone_configs = {
zone_name: {
"target": _live_target_to_config(parsed["target"]),
zone_configs: dict[str, dict[str, Any]] = {}
for zone_name, parsed in zones.items():
if not parsed["interfaces"]:
continue
zone_cfg: dict[str, Any] = {
"interfaces": parsed["interfaces"],
"services": parsed["services"],
"masquerade": parsed["masquerade"],
"rich_rules": [{"rule": r} for r in parsed["rich-rules"]],
"forward_ports": parsed["forward-ports"],
}
for zone_name, parsed in zones.items()
if parsed["interfaces"]
}
# Omit the target key when the live target normalizes to firewalld's
# implicit "default" so key-absence is the one canonical "unmanaged"
# notation; keep explicit ACCEPT/DROP/REJECT targets.
target = _live_target_to_config(parsed["target"])
if target != "DEFAULT":
zone_cfg["target"] = target
zone_configs[zone_name] = zone_cfg
if not zone_configs:
logger.debug("Skipping firewall: no zones with interfaces")
+51
View File
@@ -964,6 +964,57 @@ def status_client():
return app.test_client()
class TestStatusPending:
@_st("get")
def test_advisory_fields_passthrough(self, mock_get, status_client):
from daemon.iface import GET_STATUS_PENDING
mock_get.return_value = {
"firewall": {
"needs_apply": False,
"change_count": 0,
"changes": [],
"uncovered_interfaces": ["eth1"],
"coverage_warnings": [
"Interfaces not in any firewall zone: eth1 — clients "
"on those segments lose connectivity and DHCP"
],
},
"dnsmasq": {
"pending_changes": False,
"summary": "Up to date",
"changes": [],
},
"nginx": {"pending_changes": False, "summary": "Up to date", "changes": []},
"wireguard": {
"pending_changes": False,
"summary": "Up to date",
"changes": [],
},
"networkd": {
"pending_changes": False,
"summary": "Up to date",
"changes": [],
},
"total_changes": 0,
}
resp = status_client.get("/api/status/pending")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["firewall"]["uncovered_interfaces"] == ["eth1"]
assert data["data"]["firewall"]["coverage_warnings"]
assert data["data"]["total_changes"] == 0
mock_get.assert_called_once_with(GET_STATUS_PENDING)
@_st("get")
def test_runtime_error(self, mock_get, status_client):
mock_get.side_effect = RuntimeError("no daemon")
resp = status_client.get("/api/status/pending")
assert resp.status_code == 500
assert resp.get_json()["ok"] is False
class TestStatusRefresh:
def test_filtered_subsystems_passed_through(self, status_client):
"""The subsystem body is forwarded to the daemon POST endpoint."""
+468
View File
@@ -76,6 +76,38 @@ class TestParseZoneOutput:
assert result["services"] == ["ssh", "dhcp"]
assert result["masquerade"] is True
def test_lib_rich_rule_continuation_lines(self):
"""firewalld emits each rich rule on its own tab-indented line."""
rule = 'rule family="ipv4" port port="51820" protocol="udp" accept'
result = firewall._parse_zone_output(
"vpn-full",
(
"target: default\n"
"interfaces: \n"
"rich rules: \n"
"\t" + rule + "\n"
"masquerade: yes\n"
),
)
assert result["rich-rules"] == [rule]
assert result["masquerade"] is True
def test_lib_multiple_rich_rule_continuation_lines(self):
rule_a = 'rule family="ipv4" port port="51820" protocol="udp" accept'
rule_b = 'rule family="ipv4" source address="10.0.0.0/8" drop'
result = firewall._parse_zone_output(
"vpn-full",
("target: default\nrich rules: \n" + rule_a + "\n" + rule_b + "\n"),
)
assert result["rich-rules"] == [rule_a, rule_b]
def test_lib_no_rich_rules_when_no_continuation(self):
result = firewall._parse_zone_output(
"public",
"target: default\nrich rules: \nmasquerade: no\n",
)
assert result["rich-rules"] == []
class TestParseInterfaces:
def test_lib_parses_interfaces(self):
@@ -334,6 +366,92 @@ class TestConfigPending:
)
_PENDING_LIVE_PUBLIC = {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
"target": "default",
"rich-rules": [],
"forward-ports": [],
}
class TestComputePendingChangesAbsentInterfaces:
"""Zones whose config lacks the 'interfaces' key are hands-off on apply,
so their interfaces diff must not be reported; other field drift is."""
def test_services_drift_reported_without_interfaces_key(self):
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
types = {c["type"] for c in result["pending"]}
assert "services" in types
assert "interfaces" not in types
def test_no_spurious_interfaces_entry_for_absent_key_zone(self):
# Config in sync on everything except a missing interfaces key: the
# zone's live interfaces are intentionally left alone by apply.
cfg = {"zones": {"public": {"services": ["http"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
assert result["pending"] == []
assert result["needs_apply"] is False
def test_explicit_empty_interfaces_key_still_diffs(self):
cfg = {"zones": {"public": {"interfaces": [], "services": ["http"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
entries = [c for c in result["pending"] if c["type"] == "interfaces"]
assert len(entries) == 1
assert entries[0]["config"] == []
assert entries[0]["live"] == ["eth0"]
class TestTargetDriftSemantics:
"""Target is unmanaged when the config key is absent or normalizes to
'default' (WI-2, Option A); explicit ACCEPT/DROP/REJECT is fully managed."""
def test_absent_target_key_not_diffed(self):
cfg = {"zones": {"public": {"interfaces": ["eth0"], "services": ["http"]}}}
live = {"public": {**_PENDING_LIVE_PUBLIC, "target": "ACCEPT"}}
result = firewall._compute_pending_changes(cfg, live)
assert not any(c["type"] == "target" for c in result["pending"])
def test_explicit_default_target_not_diffed(self):
cfg = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"target": "DEFAULT",
}
}
}
live = {"public": {**_PENDING_LIVE_PUBLIC, "target": "ACCEPT"}}
result = firewall._compute_pending_changes(cfg, live)
assert not any(c["type"] == "target" for c in result["pending"])
def test_explicit_accept_target_diffed(self):
cfg = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"target": "ACCEPT",
}
}
}
live = {"public": _PENDING_LIVE_PUBLIC} # live target is 'default'
result = firewall._compute_pending_changes(cfg, live)
target_entries = [c for c in result["pending"] if c["type"] == "target"]
assert len(target_entries) == 1
assert target_entries[0]["config"] == "ACCEPT"
assert target_entries[0]["live"] == "default"
# ---------------------------------------------------------------------------
# lib/firewall.py — parse zone output (used by both lib and daemon)
# ---------------------------------------------------------------------------
@@ -530,6 +648,7 @@ class TestDaemonConfigApply:
)
def test_applies_existing_zone(self, mock_run, mock_cfg):
with (
patch("lib.network.get_config", return_value={"interfaces": {}}),
patch(
"daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json"
),
@@ -683,6 +802,245 @@ class TestDaemonMgmtLockoutGuard:
assert result["applied_zones"] == ["public"]
# ---------------------------------------------------------------------------
# Interface-coverage guard: apply must not leave a network-managed interface
# in no zone (clients lose connectivity/DHCP) unless forced.
# ---------------------------------------------------------------------------
def _make_run(
active_out: str,
zones_out: str = "public\ninternal",
zone_out: str = (
"target: default\n"
"interfaces: eth0\n"
"services: http\n"
"masquerade: no\n"
"rich-rules: \n"
"forward-ports: \n"
),
):
def _side_effect(cmd, **kwargs):
if cmd == ["firewall-cmd", "--get-active-zones"]:
return active_out
if cmd == ["firewall-cmd", "--get-zones"]:
return zones_out
if cmd and cmd[-1] == "--list-all":
return zone_out
return ""
return _side_effect
def _apply_with(
cfg: dict,
network_ifaces: dict,
active_out: str,
force: bool = False,
backup: str = "/tmp/rules.json",
):
"""Run _config_apply with the standard mock set; return (result, mock_run)."""
with (
patch("lib.network.get_config", return_value={"interfaces": network_ifaces}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run(active_out),
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup", return_value=backup),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(cfg),
),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply(force=force)
return result, mock_run
class TestDaemonInterfaceCoverageGuard:
def test_absent_key_zone_keeps_live_interfaces_on_apply(self):
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
# The guard reads live zones once, up front.
assert mock_run.call_args_list[0].args[0] == [
"firewall-cmd",
"--get-active-zones",
]
# Hands off: no interface mutation commands for the absent-key zone.
for c in mock_run.call_args_list:
for arg in c.args[0]:
assert not arg.startswith("--remove-interface=")
assert not arg.startswith("--add-interface=")
def test_explicit_empty_list_unassigns(self):
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, mock_run = _apply_with(cfg, {}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
cmds = [c.args[0] for c in mock_run.call_args_list]
assert [
"firewall-cmd",
"--zone=public",
"--remove-interface=eth0",
"--permanent",
] in cmds
assert not any(
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds
)
def test_conflict_when_network_iface_goes_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
assert "eth0" in str(exc.value)
assert "force" in str(exc.value)
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_live_only_zone_interfaces_count_as_covered(self):
# eth1 is held by 'guest', which is live but absent from the config —
# apply never touches it, so eth1 counts as covered.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"eth1": {}}, "public\n eth0\nguest\n eth1\n")
assert result["applied_zones"] == ["public"]
def test_coverage_guard_conflict_writes_no_backup(self):
# Guard conflict must be side-effect free, like the lockout conflict.
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError),
):
daemonfirewall._config_apply()
mock_backup.assert_not_called()
# Only the guard's live-zone read ran — no mutation commands at all.
assert [c.args[0] for c in mock_run.call_args_list] == [
["firewall-cmd", "--get-active-zones"]
]
# ---------------------------------------------------------------------------
# create_zone — must run --new-zone first, then set only non-default targets
# ---------------------------------------------------------------------------
class TestDaemonCreateZone:
def _run(self, body, run_return="public internal"):
with (
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.create_zone(None, body)
return result, mock_run
def test_new_zone_always_created(self):
result, mock_run = self._run({"name": "guest"})
assert result == {"zone": "guest"}
cmds = [c.args[0] for c in mock_run.call_args_list]
assert ["firewall-cmd", "--new-zone=guest", "--permanent"] in cmds
def test_default_target_not_set(self):
result, mock_run = self._run({"name": "guest", "target": "default"})
assert result == {"zone": "guest"}
cmds = [c.args[0] for c in mock_run.call_args_list]
assert not any("--set-target=" in " ".join(c) for c in cmds)
def test_accept_target_is_set(self):
result, mock_run = self._run({"name": "guest", "target": "ACCEPT"})
assert result == {"zone": "guest"}
cmds = [c.args[0] for c in mock_run.call_args_list]
assert [
"firewall-cmd",
"--zone=guest",
"--set-target=ACCEPT",
"--permanent",
] in cmds
def test_existing_zone_rejected(self):
with (
patch("daemon.handlers.firewall.run", return_value="public guest"),
pytest.raises(ValueError),
):
daemonfirewall.create_zone(None, {"name": "guest"})
# ---------------------------------------------------------------------------
# Pre-apply recovery snapshot (single backup write with a real payload)
# ---------------------------------------------------------------------------
class TestDaemonConfigApplyBackup:
def test_pre_apply_snapshot_shape(self):
# public carries https+ssh so the default-zone lockout guard does not fire.
cfg = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["https", "ssh"],
}
}
}
with (
patch("lib.network.get_config", return_value={"interfaces": {}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="public"),
patch(
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
) as mock_backup,
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply()
assert result["backup"] == "/tmp/rules.json"
# A single pre-apply snapshot (no post-apply skeleton write).
assert mock_backup.call_count == 1
snapshot = mock_backup.call_args[0][0]
assert {"timestamp", "default_zone", "zones", "config"} <= set(snapshot)
assert snapshot["default_zone"] == "public"
assert snapshot["config"] == cfg
# ---------------------------------------------------------------------------
# Applied-baseline stamping
# ---------------------------------------------------------------------------
_STAMP_TEST_CFG = {
"zones": {
"public": {
@@ -715,6 +1073,7 @@ class TestDaemonConfigApplyStamp:
)
def test_stamps_applied_baseline(self, mock_run, mock_cfg):
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
@@ -1060,6 +1419,23 @@ class TestParseAllZonesOutput:
assert result["internal"]["target"] == "ACCEPT"
assert result["trusted"]["services"] == []
def test_parses_rich_rule_continuation_lines(self):
rule = 'rule family="ipv4" port port="51820" protocol="udp" accept'
result = firewall._parse_all_zones_output(
"vpn-full\n"
" target: default\n"
" interfaces: \n"
" rich rules: \n"
"\t" + rule + "\n"
" masquerade: yes\n"
"dmz\n"
" target: default\n"
" rich rules: \n"
)
assert result["vpn-full"]["rich-rules"] == [rule]
assert result["vpn-full"]["masquerade"] is True
assert result["dmz"]["rich-rules"] == []
def test_empty_output(self):
assert firewall._parse_all_zones_output("") == {}
assert firewall._parse_all_zones_output("\n \n") == {}
@@ -1101,3 +1477,95 @@ class TestParseAllZonesOutput:
"rich-rules",
):
assert field in zone, f"Missing field: {field}"
# ---------------------------------------------------------------------------
# lib/firewall.py — service catalog descriptions
# ---------------------------------------------------------------------------
def _write_service(dir, name, body):
(dir / f"{name}.xml").write_text(body)
class TestGetServiceDescriptions:
def test_parses_short_preferred(self, tmp_path):
d1 = tmp_path / "builtin"
d2 = tmp_path / "etc"
d1.mkdir()
d2.mkdir()
_write_service(
d1,
"ssh",
"<service><short>OpenSSH</short><description>Remote login</description></service>",
)
_write_service(
d1,
"http",
"<service><short>WWW</short><description>Web server</description></service>",
)
_write_service(
d2,
"custom",
"<service><description>Only a long description</description></service>",
)
result = firewall.get_service_descriptions([d1, d2])
assert result == {
"ssh": "OpenSSH",
"http": "WWW",
"custom": "Only a long description",
}
def test_description_fallback_when_no_short(self, tmp_path):
d = tmp_path / "svc"
d.mkdir()
_write_service(
d,
"ntp",
"<service><description>Time synchronization</description></service>",
)
assert firewall.get_service_descriptions([d]) == {"ntp": "Time synchronization"}
def test_etc_overrides_builtin(self, tmp_path):
builtin = tmp_path / "builtin"
etc = tmp_path / "etc"
builtin.mkdir()
etc.mkdir()
_write_service(builtin, "ssh", "<service><short>Built-in SSH</short></service>")
_write_service(etc, "ssh", "<service><short>Custom SSH</short></service>")
# builtin listed first, etc second (same order as the default dirs)
assert firewall.get_service_descriptions([builtin, etc]) == {
"ssh": "Custom SSH"
}
def test_missing_dirs_return_empty(self, tmp_path):
assert (
firewall.get_service_descriptions([tmp_path / "nope1", tmp_path / "nope2"])
== {}
)
def test_malformed_xml_skipped(self, tmp_path):
d = tmp_path / "svc"
d.mkdir()
_write_service(d, "broken", "<service><short>Unclosed")
_write_service(d, "good", "<service><short>Works</short></service>")
result = firewall.get_service_descriptions([d])
assert result == {"good": "Works"}
def test_empty_text_not_recorded(self, tmp_path):
d = tmp_path / "svc"
d.mkdir()
_write_service(d, "blank", "<service></service>")
assert firewall.get_service_descriptions([d]) == {}
def test_explicit_dirs_not_cached(self, tmp_path):
d1 = tmp_path / "first"
d2 = tmp_path / "second"
d1.mkdir()
d2.mkdir()
_write_service(d1, "a", "<service><short>A1</short></service>")
result1 = firewall.get_service_descriptions([d1])
_write_service(d2, "a", "<service><short>A2</short></service>")
result2 = firewall.get_service_descriptions([d1, d2])
assert result1 == {"a": "A1"}
assert result2 == {"a": "A2"}
+18 -1
View File
@@ -19,7 +19,21 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
class TestCollectorShapesMatchSchema:
def test_firewall_state(self):
with patch.object(lib.state, "run") as mock_run:
with (
patch.object(lib.state, "run") as mock_run,
patch.object(
lib.state,
"_network_get_config",
return_value={
"interfaces": {
"eth0": {},
"eth1": {},
"lo": {},
"wg0": {},
}
},
),
):
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -55,6 +69,9 @@ class TestCollectorShapesMatchSchema:
for iface in result["interfaces"]:
for k in schema.FirewallInterface.__required_keys__:
assert k in iface, f"FirewallInterface missing {k}"
# eth1 is network-config-managed but in no live zone; lo and wg*
# are filtered out even though they are present in the config.
assert result["uncovered_interfaces"] == ["eth1"]
def test_dnsmasq_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
+24
View File
@@ -143,6 +143,30 @@ class TestCollectAll:
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
assert "10.0.0.1/24" in vlan_iface["ips"]
@patch("lib.state.get_service_descriptions")
@patch("lib.state.run")
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
from lib.state import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
return ""
if "--get-default-zone" in args:
return "public\n"
if "--get-services" in args:
return "ssh http"
if "ip" in args[0]:
return ""
if "--list-all-zones" in args:
return ""
mock_run.side_effect = run_side
descs = {"ssh": "OpenSSH", "http": "WWW"}
mock_desc.return_value = descs
result = _collect_firewall()
mock_desc.assert_called_once_with()
assert result["service_descriptions"] == descs
@patch("lib.state.run_proc")
def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock
+56
View File
@@ -183,6 +183,62 @@ class TestStatusPending:
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
def test_firewall_uncovered_advisory_not_counted(self):
with self._patch_store(
{
"firewall": {
"pending": {"needs_apply": False, "pending": []},
"uncovered_interfaces": ["eth1"],
},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["firewall"]["uncovered_interfaces"] == ["eth1"]
assert result["firewall"]["coverage_warnings"]
assert "eth1" in result["firewall"]["coverage_warnings"][0]
# Advisory: must not flip needs_apply or count as a change.
assert not result["firewall"]["needs_apply"]
assert result["firewall"]["change_count"] == 0
assert result["total_changes"] == 0
def test_firewall_no_uncovered_no_warnings(self):
with self._patch_store(
{
"firewall": {
"pending": {"needs_apply": False, "pending": []},
"uncovered_interfaces": [],
},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["firewall"]["uncovered_interfaces"] == []
assert result["firewall"]["coverage_warnings"] == []
assert result["total_changes"] == 0
def test_firewall_missing_uncovered_key_defaults_empty(self):
with self._patch_store(
{
"firewall": {
"pending": {"needs_apply": False, "pending": []},
},
"dnsmasq": None,
"nginx": None,
"wireguard": None,
"networkd": None,
}
):
result = status.status_pending(None, None)
assert result["firewall"]["uncovered_interfaces"] == []
assert result["firewall"]["coverage_warnings"] == []
def test_firewall_no_pending_key(self):
with self._patch_store(
{
+28 -21
View File
@@ -764,7 +764,10 @@ class TestFirewallToDhcpSync:
@patch("lib.dnsmasq.save_config")
@patch("lib.dnsmasq.get_config")
@patch("lib.firewall.get_config")
def test_removes_stale_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save):
def test_flags_uncovered_range_without_deleting(
self, mock_fw_get, mock_dm_get, mock_dm_save, caplog
):
caplog.set_level(logging.WARNING)
mock_fw_get.return_value = {
"zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}}
}
@@ -790,16 +793,16 @@ class TestFirewallToDhcpSync:
)
assert result is not None
assert "dnsmasq" in result.affected_subsystems
assert any("Removed stale DHCP range" in c for c in result.changes)
assert any("eth2" in c for c in result.changes)
assert result.affected_subsystems == []
assert result.changes == [
"DHCP range on 'eth2' has no firewall zone coverage — "
"inactive until a zone covers it"
]
assert "DHCP range on 'eth2' has no firewall zone coverage" in caplog.text
# Verify saved config only has eth1 range
mock_dm_save.assert_called_once()
saved = mock_dm_save.call_args[0][0]
saved_ranges = saved["dhcp"]["ranges"]
assert len(saved_ranges) == 1
assert saved_ranges[0]["interface"] == "eth1"
# Config untouched: no save, both ranges kept
mock_dm_save.assert_not_called()
assert len(mock_dm_get.return_value["dhcp"]["ranges"]) == 2
@patch("lib.dnsmasq.get_config")
@patch("lib.firewall.get_config")
@@ -869,8 +872,9 @@ class TestFirewallToDhcpSync:
@patch("lib.dnsmasq.save_config")
@patch("lib.dnsmasq.get_config")
@patch("lib.firewall.get_config")
def test_keeps_global_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save):
"""Ranges without an interface (global) are never removed."""
def test_keeps_global_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save, caplog):
"""Global and uncovered ranges are both kept, never removed."""
caplog.set_level(logging.WARNING)
mock_fw_get.return_value = {
"zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}}
}
@@ -892,16 +896,19 @@ class TestFirewallToDhcpSync:
)
assert result is not None
assert "dnsmasq" in result.affected_subsystems
saved = mock_dm_save.call_args[0][0]
saved_ranges = saved["dhcp"]["ranges"]
assert len(saved_ranges) == 1
assert saved_ranges[0]["start"] == "192.168.1.100"
assert (
saved_ranges[0].get("interface") is None
or saved_ranges[0]["interface"] == ""
assert result.affected_subsystems == []
assert any(
"DHCP range on 'eth2' has no firewall zone coverage" in c
for c in result.changes
)
assert "DHCP range on 'eth2' has no firewall zone coverage" in caplog.text
# No save; both ranges (global + eth2) kept in the untouched config
mock_dm_save.assert_not_called()
ranges = mock_dm_get.return_value["dhcp"]["ranges"]
assert len(ranges) == 2
assert ranges[0]["start"] == "192.168.1.100"
assert ranges[0].get("interface") is None or ranges[0]["interface"] == ""
@patch("lib.dnsmasq.save_config")
@patch("lib.dnsmasq.get_config")
+26 -2
View File
@@ -552,7 +552,9 @@ class TestImportFirewall:
assert "zones" in cfg
assert "public" in cfg["zones"]
assert "internal" in cfg["zones"]
assert cfg["zones"]["public"]["target"] == "DEFAULT"
# Live target normalizes to "default" -> the target key is omitted
# (key-absence is the canonical "unmanaged" notation, WI-2).
assert "target" not in cfg["zones"]["public"]
assert cfg["zones"]["public"]["interfaces"] == ["eth0", "eth1"]
assert cfg["zones"]["public"]["services"] == [
"dhcpv6-cidr",
@@ -560,9 +562,31 @@ class TestImportFirewall:
"mdns",
"ssh",
]
assert cfg["zones"]["internal"]["target"] == "DEFAULT"
assert "target" not in cfg["zones"]["internal"]
assert cfg["zones"]["internal"]["interfaces"] == ["eth2"]
def test_import_keeps_nondefault_target(self, temp_project, tmp_path):
# A zone with interfaces and a non-default live target keeps its
# explicit target key (ACCEPT/DROP/REJECT remain fully managed).
output = (
"trusted (active)\n"
" target: ACCEPT\n"
" interfaces: eth3\n"
" sources: \n"
" services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" source-ports: \n"
" icmp-blocks: \n"
" rich rules: \n"
)
with patch("lib.system_import.run", return_value=output):
assert system_import.import_firewall()
cfg = self._read_json(tmp_path)
assert cfg["zones"]["trusted"]["target"] == "ACCEPT"
assert cfg["zones"]["trusted"]["interfaces"] == ["eth3"]
def test_empty_interface_zones_skipped(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
assert system_import.import_firewall()
+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 {