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