refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7 modules, registration side-effect; daemon/server.py imports the package before the first populate()) - webui/api: new daemon_route() decorator factory in common.py collapses the try/except daemon-proxy boilerplate in all 8 blueprints (rules/params/body/transform keep responses identical) - firewall: interface-coverage invariant — config is the source of truth for zone interfaces (absent key = empty, no hands-off zones); pure validate_coverage() enforced at save (400) and apply (409, force: true overrides), top-level `unmanaged` exemption - lib: get_config() reads are now pure (no dir creation or writes); new lib/bootstrap.py creates runtime dirs and persists the one-shot nginx legacy migration at daemon start, after system_import (lib.nginx.migrate_config_file) - lib/common: compute_pending() apply-bookkeeping helper - daemon: emit_and_refresh() handler helper; refresh_state(bump=) so /status/refresh no longer bumps versions (poll/mutation only) - acme: move --log last so acme.sh never treats a real arg as the log-file argument - docs: AGENTS.md, config.md, state-model.md, api.md updated; HARDEN.md dropped (plan implemented); apply-confirm force wording Tests: 917 passed; ruff check + format clean.
This commit is contained in:
@@ -39,10 +39,15 @@ Basic auth (`.htpasswd`) renders **only** for proxy domains whose
|
|||||||
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
|
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
|
||||||
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
|
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
|
||||||
- `daemon/iface.py` — **Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
|
- `daemon/iface.py` — **Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
|
||||||
- `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
|
- `daemon/handlers/*.py` — Privileged operation handlers. All mutating `sudo` calls live here.
|
||||||
|
- `daemon/collectors/` — Per-subsystem state collectors (7 modules: firewall, dnsmasq, nginx, acme, wireguard, networkd, system). Read-only `sudo` queries that populate `lib.state`. Imported for their registration side-effect; `daemon/server.py` imports the package before the first `populate()`.
|
||||||
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream: `get_snapshot()` (full state on WS connect), `poll()` two-layer diff (structural `versions` broadcast vs volatile-only `tick` broadcast, per-subsystem, each carrying the full subsystem data), `register_volatile(subsystem, keys)` to mark volatile fields, `get_versions()`/`bump()`. Per-subsystem poll intervals via `_DEFAULT_POLL_INTERVALS` (system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s).
|
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream: `get_snapshot()` (full state on WS connect), `poll()` two-layer diff (structural `versions` broadcast vs volatile-only `tick` broadcast, per-subsystem, each carrying the full subsystem data), `register_volatile(subsystem, keys)` to mark volatile fields, `get_versions()`/`bump()`. Per-subsystem poll intervals via `_DEFAULT_POLL_INTERVALS` (system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s).
|
||||||
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
|
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`, plus the apply-bookkeeping helpers `stamp_applied()` / `strip_apply_meta()` / `compute_pending()` / `deep_diff()` / `revert_to_applied()` (the `_last_applied_hash` / `_last_applied_config` keys every config-backed subsystem uses for pending-change detection and cancel-all).
|
||||||
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
|
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
|
||||||
|
- `lib/sync.py` — Cross-subsystem sync event bus (in-process pub/sub); handlers emit events on mutation and subscribers refresh affected subsystems.
|
||||||
|
- `lib/system_import.py` — Startup system-config import/reconcile: parses native config sources and merges them into the declarative JSON on daemon start.
|
||||||
|
- `lib/bootstrap.py` — Daemon-startup filesystem bootstrap, run **after** `system_import.import_all()` (which must see absent config files to adopt live state on first start) and before the first state collection: creates the runtime `config/`+`data/` directories and persists the one-shot nginx legacy-format migration. Never creates config files (reads stay pure; files appear on first `save_config`).
|
||||||
|
- `lib/schema.py` — TypedDict state schemas for the per-subsystem state payloads.
|
||||||
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
|
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
|
||||||
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
|
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
|
||||||
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
|
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
|
||||||
@@ -118,6 +123,21 @@ Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, the
|
|||||||
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
|
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
|
||||||
Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code.
|
Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code.
|
||||||
|
|
||||||
|
**Config reads are pure.** Every `lib/<subsystem>.get_config()` is a side-effect-free
|
||||||
|
read (returns in-memory defaults when the file is missing; nginx applies its
|
||||||
|
legacy-format migration in memory). State collectors therefore never write to
|
||||||
|
disk — filesystem setup (runtime dirs, one-shot nginx migration) happens once at
|
||||||
|
daemon startup in `lib/bootstrap.py` (after `system_import.import_all()`).
|
||||||
|
|
||||||
|
**Firewall interface-coverage invariant.** Every network-managed interface
|
||||||
|
(`lo`/`wg*` excluded) must be covered by a zone in `config/firewall/config.json`
|
||||||
|
or listed under the top-level `unmanaged` key. The config is the source of truth
|
||||||
|
for zone interfaces (an omitted `interfaces` key = empty list; no hands-off
|
||||||
|
zones). Enforced at save time (`POST`/`PATCH /firewall/config` → 400) and apply
|
||||||
|
time (`POST /firewall/config/apply` → 409, `force: true` overrides) via the pure
|
||||||
|
`lib.firewall.validate_coverage()`. Live drift is advisory only
|
||||||
|
(`uncovered_interfaces` state field). See `docs/config.md`.
|
||||||
|
|
||||||
## API Response Contract
|
## API Response Contract
|
||||||
|
|
||||||
- Success: `{"ok": true, "data": <value>}` — `_ok(data)` (Flask) or `ok(data)` (aiohttp)
|
- Success: `{"ok": true, "data": <value>}` — `_ok(data)` (Flask) or `ok(data)` (aiohttp)
|
||||||
@@ -140,7 +160,7 @@ user (full `rw` on all subsystems; default username `admin`), **not** an nginx h
|
|||||||
|
|
||||||
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
|
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
|
||||||
|
|
||||||
**Tests:** pytest in `tests/` (17 modules). All subprocess calls are mocked — no system services required.
|
**Tests:** pytest in `tests/` (27 test files). All subprocess calls are mocked — no system services required.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
||||||
@@ -160,4 +180,5 @@ user (full `rw` on all subsystems; default username `admin`), **not** an nginx h
|
|||||||
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
|
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
|
||||||
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
|
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
|
||||||
| `docs/hoover.md` | Custom frontend framework API reference |
|
| `docs/hoover.md` | Custom frontend framework API reference |
|
||||||
|
| `docs/state-model.md` | Per-subsystem state schema, the `versions`/`tick` two-layer diff, and the pending-changes model |
|
||||||
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
|
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""State collectors for vacuum-walld.
|
||||||
|
|
||||||
|
Importing this package registers every collector with the ``lib.state``
|
||||||
|
store (registration side effect). Import it before the first
|
||||||
|
``populate()``/``poll()`` call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from daemon.collectors import (
|
||||||
|
acme,
|
||||||
|
dnsmasq,
|
||||||
|
firewall,
|
||||||
|
networkd,
|
||||||
|
nginx,
|
||||||
|
system,
|
||||||
|
wireguard,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"acme",
|
||||||
|
"dnsmasq",
|
||||||
|
"firewall",
|
||||||
|
"networkd",
|
||||||
|
"nginx",
|
||||||
|
"system",
|
||||||
|
"wireguard",
|
||||||
|
]
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""ACME state collector."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib import schema
|
||||||
|
from lib.common import load_json
|
||||||
|
from lib.state import PROJECT_DIR, _now_iso, register_collector
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CA_NAME_MAP: dict[str, str] = {
|
||||||
|
"letsencrypt": "Let's Encrypt",
|
||||||
|
"zerossl": "ZeroSSL",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ca_name(ca_server: str) -> str:
|
||||||
|
"""Map a CA server identifier to its human-readable name.
|
||||||
|
|
||||||
|
Uses prefix matching sorted by longest prefix first to avoid
|
||||||
|
shorter prefixes winning (e.g. "letsencrypt" matching before
|
||||||
|
"letsencrypt.org").
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ca_server: Raw CA server string from acme.sh config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable name, or unchanged string if no match.
|
||||||
|
"""
|
||||||
|
for prefix, name in sorted(
|
||||||
|
_CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
|
||||||
|
):
|
||||||
|
if ca_server.startswith(prefix):
|
||||||
|
return name
|
||||||
|
return ca_server
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
||||||
|
"""Parse acme.sh account information and return account status dict.
|
||||||
|
|
||||||
|
Checks three sources in order:
|
||||||
|
1. Legacy ``.account.conf`` file (acme.sh v2.x format)
|
||||||
|
2. Declarative ``config/acme/config.json`` (saved by the registration
|
||||||
|
handler with ``email`` and ``ca`` fields)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
acme_home: Optional override for ACME home directory. Falls back
|
||||||
|
to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``registered``, ``email``, ``ca``, and
|
||||||
|
``key_length`` keys. If no account is found, ``registered`` is
|
||||||
|
``False`` with empty / ``None`` values.
|
||||||
|
"""
|
||||||
|
if acme_home is None:
|
||||||
|
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||||
|
acme_home = Path(acme_home_env)
|
||||||
|
|
||||||
|
default = {
|
||||||
|
"registered": False,
|
||||||
|
"email": "",
|
||||||
|
"ca": "",
|
||||||
|
"key_length": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Legacy .account.conf (acme.sh v2.x)
|
||||||
|
account_path = acme_home / ".account.conf"
|
||||||
|
if account_path.is_file():
|
||||||
|
try:
|
||||||
|
text = account_path.read_text()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
email = ""
|
||||||
|
ca_raw = ""
|
||||||
|
key_length = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("ACME_LEEMAIL="):
|
||||||
|
email = line.split("=", 1)[1].strip().strip("'\"")
|
||||||
|
elif line.startswith("ACME_MCA="):
|
||||||
|
ca_raw = line.split("=", 1)[1].strip().strip("'\"")
|
||||||
|
elif line.startswith("ACME_CERTKEYSIZE="):
|
||||||
|
raw_val = line.split("=", 1)[1].strip().strip("'\"")
|
||||||
|
key_length = int(raw_val) if raw_val.isdigit() else None
|
||||||
|
if email and ca_raw:
|
||||||
|
return {
|
||||||
|
"registered": True,
|
||||||
|
"email": email,
|
||||||
|
"ca": _resolve_ca_name(ca_raw),
|
||||||
|
"key_length": key_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Declarative config (saved by register_account / set_email handlers)
|
||||||
|
# Modern acme.sh (v3.x) stores account data in per-CA JSON files
|
||||||
|
# (ca/<server>/account.json) — we can't reliably parse those without
|
||||||
|
# walking the directory, so fall back to the declarative config
|
||||||
|
# which the handlers keep in sync.
|
||||||
|
# Derive project root from acme_home (acme_home is at <root>/data/acme).
|
||||||
|
try:
|
||||||
|
project_root = acme_home.parent.parent # data/acme → data → project root
|
||||||
|
acme_cfg = project_root / "config" / "acme" / "config.json"
|
||||||
|
data = load_json(acme_cfg)
|
||||||
|
email = (data.get("email") or "").strip()
|
||||||
|
ca_raw = (data.get("ca") or "").strip()
|
||||||
|
if email and ca_raw:
|
||||||
|
return {
|
||||||
|
"registered": True,
|
||||||
|
"email": email,
|
||||||
|
"ca": _resolve_ca_name(ca_raw),
|
||||||
|
"key_length": None,
|
||||||
|
}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _get_acme_email() -> str:
|
||||||
|
"""Read the ACME ``acme.sh`` email from the account config file.
|
||||||
|
|
||||||
|
Falls back to the declarative ACME config (config/acme/config.json)
|
||||||
|
if acme.sh account has not been registered yet.
|
||||||
|
"""
|
||||||
|
from lib.acme import _read_acme_email
|
||||||
|
|
||||||
|
return _read_acme_email()
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_acme() -> schema.AcmeState:
|
||||||
|
"""Collect ACME certificate list and email.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing certificate details and registered email.
|
||||||
|
"""
|
||||||
|
email = _get_acme_email()
|
||||||
|
|
||||||
|
# Non-fatal: a broken acme.sh (e.g. unreadable account.conf after an
|
||||||
|
# ownership flip) must not blank the whole dashboard via a cleared
|
||||||
|
# state store. Collect what we can and surface the failure in
|
||||||
|
# `status.error` so the poll diff still detects recovery.
|
||||||
|
cert_error: str | None = None
|
||||||
|
try:
|
||||||
|
from lib.acme import list_certs
|
||||||
|
|
||||||
|
certs = list_certs()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ACME state collection failed", exc_info=True)
|
||||||
|
certs = []
|
||||||
|
cert_error = str(exc)
|
||||||
|
|
||||||
|
account = _parse_account_conf()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"certs": certs,
|
||||||
|
"email": email,
|
||||||
|
"account": account,
|
||||||
|
"status": {"error": cert_error},
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("acme", _collect_acme)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""DNSMasq state collector."""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib import schema
|
||||||
|
from lib.common import compute_pending, run_proc, strip_apply_meta
|
||||||
|
from lib.dnsmasq import DEFAULT_CFG, get_config
|
||||||
|
from lib.state import _now_iso, register_collector
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_dnsmasq() -> schema.DnsmasqState:
|
||||||
|
"""Collect dnsmasq status, config, and leases.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing config, service status, leases, and timestamp.
|
||||||
|
"""
|
||||||
|
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||||
|
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
|
||||||
|
|
||||||
|
# Load config (lib defaults; fall back to them when the file is broken)
|
||||||
|
try:
|
||||||
|
cfg = get_config()
|
||||||
|
except Exception:
|
||||||
|
cfg = deepcopy(DEFAULT_CFG)
|
||||||
|
|
||||||
|
# Service status
|
||||||
|
service_active = False
|
||||||
|
try:
|
||||||
|
proc = run_proc(["systemctl", "is-active", "dnsmasq"], sudo=True)
|
||||||
|
service_active = proc.stdout.strip() == "active"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Leases
|
||||||
|
leases: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
result = run_proc(["cat", LEASE_FILE], sudo=True, check=True)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
ts = None
|
||||||
|
leases.append(
|
||||||
|
{
|
||||||
|
"expires": ts.isoformat() if ts else "",
|
||||||
|
"mac": parts[1],
|
||||||
|
"ip": parts[2],
|
||||||
|
"hostname": parts[3] if len(parts) > 3 else "",
|
||||||
|
"interface": parts[4] if len(parts) > 4 else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check config file on disk
|
||||||
|
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||||
|
|
||||||
|
pending_changes, pending_diff = compute_pending(cfg)
|
||||||
|
safe_cfg = strip_apply_meta(cfg)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"config": safe_cfg,
|
||||||
|
"status": {
|
||||||
|
"service_active": service_active,
|
||||||
|
"config_file_exists": conf_exists,
|
||||||
|
"active_leases": len(leases),
|
||||||
|
"pending_changes": pending_changes,
|
||||||
|
"pending_diff": pending_diff,
|
||||||
|
},
|
||||||
|
"leases": leases,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("dnsmasq", _collect_dnsmasq)
|
||||||
|
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Firewall state collector (read-only sudo queries)."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib import schema
|
||||||
|
from lib.common import load_json, run, strip_apply_meta
|
||||||
|
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.state import (
|
||||||
|
PROJECT_DIR,
|
||||||
|
_now_iso,
|
||||||
|
register_collector,
|
||||||
|
register_volatile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||||
|
"""Convert a port-forward dict to a compact string representation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fp: Port-forward entry containing port and proto keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``).
|
||||||
|
"""
|
||||||
|
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
||||||
|
if "toaddr" in fp:
|
||||||
|
parts.append(f"toaddr={fp['toaddr']}")
|
||||||
|
if "toport" in fp:
|
||||||
|
parts.append(f"toport={fp['toport']}")
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_firewall() -> schema.FirewallState:
|
||||||
|
"""Return the complete current state of firewalld.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing firewall zones, interfaces, rules, config, and
|
||||||
|
pending changes.
|
||||||
|
"""
|
||||||
|
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||||
|
active = _parse_active_zones(active_raw)
|
||||||
|
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
|
||||||
|
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
|
||||||
|
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
||||||
|
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
||||||
|
|
||||||
|
iface_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for line in link_out.splitlines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
raw_name = parts[1].rstrip(":").split("@")[0]
|
||||||
|
iface_state = "UNKNOWN"
|
||||||
|
mtu = None
|
||||||
|
mac = None
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p == "state" and i + 1 < len(parts):
|
||||||
|
iface_state = parts[i + 1]
|
||||||
|
if p == "mtu" and i + 1 < len(parts):
|
||||||
|
mtu = int(parts[i + 1])
|
||||||
|
if p.startswith("link/ether") and i + 1 < len(parts):
|
||||||
|
mac = parts[i + 1]
|
||||||
|
iface_map[raw_name] = {
|
||||||
|
"name": raw_name,
|
||||||
|
"mac": mac,
|
||||||
|
"state": iface_state,
|
||||||
|
"mtu": mtu,
|
||||||
|
"ips": [],
|
||||||
|
"ipv6": [],
|
||||||
|
"zone": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in addr_out.splitlines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
addr_name = parts[1].split("@")[0]
|
||||||
|
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||||
|
for entry in iface_map.values():
|
||||||
|
if entry["name"] == addr_name:
|
||||||
|
entry[addr_key].append(parts[3])
|
||||||
|
break
|
||||||
|
|
||||||
|
for zone_name, ifaces in active.items():
|
||||||
|
for raw_if in ifaces:
|
||||||
|
for entry in iface_map.values():
|
||||||
|
if entry["name"] == raw_if:
|
||||||
|
entry["zone"] = zone_name
|
||||||
|
break
|
||||||
|
|
||||||
|
ifaces = list(iface_map.values())
|
||||||
|
|
||||||
|
# Collect all zones in a single call (replaces per-zone loop)
|
||||||
|
zones: dict[str, dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
|
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
|
||||||
|
zones = _parse_all_zones_output(all_zones_raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Load config (strip apply bookkeeping keys, as the other collectors do)
|
||||||
|
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
|
||||||
|
config_data = {}
|
||||||
|
if fw_config_path.exists():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
config_data = strip_apply_meta(load_json(fw_config_path))
|
||||||
|
|
||||||
|
# Pending changes
|
||||||
|
full_state = {
|
||||||
|
"active_zones": active,
|
||||||
|
"default_zone": default_zone,
|
||||||
|
"interfaces": ifaces,
|
||||||
|
"available_services": services,
|
||||||
|
"zones": zones,
|
||||||
|
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
pending = {}
|
||||||
|
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,
|
||||||
|
"pending": pending,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("firewall", _collect_firewall)
|
||||||
|
register_volatile(
|
||||||
|
"firewall",
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"interfaces[].ips",
|
||||||
|
"interfaces[].ipv6",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Networkd state collector."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib import schema
|
||||||
|
from lib.common import compute_pending, run, strip_apply_meta
|
||||||
|
from lib.network import get_config, parse_networkctl_status
|
||||||
|
from lib.state import _now_iso, register_collector, register_volatile
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_networkd() -> schema.NetworkdState:
|
||||||
|
"""Collect networkd interface state from networkctl.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with interface runtime state parsed from networkctl output,
|
||||||
|
config, and pending changes status.
|
||||||
|
"""
|
||||||
|
# Load config
|
||||||
|
try:
|
||||||
|
net_cfg = get_config()
|
||||||
|
except Exception:
|
||||||
|
net_cfg = {}
|
||||||
|
|
||||||
|
pending_changes, net_pending_diff = compute_pending(net_cfg)
|
||||||
|
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
safe_net_cfg = strip_apply_meta(net_cfg)
|
||||||
|
net_status: dict[str, Any] = {
|
||||||
|
"pending_changes": pending_changes,
|
||||||
|
"pending_diff": net_pending_diff,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||||
|
result = parse_networkctl_status(raw)
|
||||||
|
if not result:
|
||||||
|
return {
|
||||||
|
"interfaces": {},
|
||||||
|
"config": safe_net_cfg,
|
||||||
|
"status": net_status,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"interfaces": {},
|
||||||
|
"config": safe_net_cfg,
|
||||||
|
"status": net_status,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"interfaces": result,
|
||||||
|
"config": safe_net_cfg,
|
||||||
|
"status": net_status,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("networkd", _collect_networkd)
|
||||||
|
register_volatile(
|
||||||
|
"networkd",
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"interfaces[].addresses",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Nginx state collector."""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib import schema
|
||||||
|
from lib.common import compute_pending, strip_apply_meta
|
||||||
|
from lib.nginx import DEFAULT_CONFIG, SITES_DIR, _resolve_paths, get_config
|
||||||
|
from lib.state import _now_iso, register_collector
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_nginx() -> schema.NginxState:
|
||||||
|
"""Collect nginx config and domains list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing config, domains, and timestamp.
|
||||||
|
"""
|
||||||
|
# Load config via lib.nginx — a pure read that applies the legacy-format
|
||||||
|
# migration in memory (the one-shot on-disk migration runs at daemon
|
||||||
|
# startup, see lib.bootstrap).
|
||||||
|
try:
|
||||||
|
cfg = get_config()
|
||||||
|
except Exception:
|
||||||
|
cfg = deepcopy(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
# Build flattened domains list (one entry per path)
|
||||||
|
backends = cfg.get("backends", {})
|
||||||
|
domains: list[dict[str, Any]] = []
|
||||||
|
for name, dom in cfg.get("domains", {}).items():
|
||||||
|
if "backend" not in dom:
|
||||||
|
continue
|
||||||
|
site = SITES_DIR / f"{name}.conf"
|
||||||
|
paths = _resolve_paths(dom, backends)
|
||||||
|
if not paths:
|
||||||
|
continue
|
||||||
|
for ppath, pcfg in paths.items():
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"domain": name,
|
||||||
|
"path": ppath,
|
||||||
|
"backend": pcfg.get("backend", {}),
|
||||||
|
"online": site.exists() if SITES_DIR.exists() else False,
|
||||||
|
"force_ssl": dom.get("force_ssl", True),
|
||||||
|
"backend_name": dom["backend"],
|
||||||
|
"cert": dom.get("cert"),
|
||||||
|
}
|
||||||
|
if pcfg.get("is_management"):
|
||||||
|
entry["is_management"] = True
|
||||||
|
if pcfg.get("is_websocket"):
|
||||||
|
entry["is_websocket"] = True
|
||||||
|
domains.append(entry)
|
||||||
|
|
||||||
|
pending_changes, nginx_pending_diff = compute_pending(cfg)
|
||||||
|
safe_cfg = strip_apply_meta(cfg)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"config": safe_cfg,
|
||||||
|
"domains": domains,
|
||||||
|
"status": {
|
||||||
|
"pending_changes": pending_changes,
|
||||||
|
"pending_diff": nginx_pending_diff,
|
||||||
|
},
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("nginx", _collect_nginx)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""System metrics collector."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib import schema
|
||||||
|
from lib.state import _now_iso, register_collector, register_volatile
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meminfo() -> dict[str, Any]:
|
||||||
|
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
|
||||||
|
info: dict[str, int] = {}
|
||||||
|
try:
|
||||||
|
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
parts = value.strip().split()
|
||||||
|
val = int(parts[0])
|
||||||
|
# Convert kB to bytes
|
||||||
|
if parts and parts[-1] == "kB":
|
||||||
|
val *= 1024
|
||||||
|
info[key] = val
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_system() -> schema.SystemState:
|
||||||
|
"""Collect system-wide metrics: CPU load, memory, network traffic.
|
||||||
|
|
||||||
|
Reads from /proc and /sys — no subprocess needed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
|
||||||
|
"""
|
||||||
|
# CPU load
|
||||||
|
loads = []
|
||||||
|
try:
|
||||||
|
parts = Path("/proc/loadavg").read_text().split()
|
||||||
|
loads = [float(x) for x in parts[:3]]
|
||||||
|
except (OSError, ValueError):
|
||||||
|
loads = [0.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
# Memory
|
||||||
|
meminfo_raw = _parse_meminfo()
|
||||||
|
mem_total = meminfo_raw.get("MemTotal", 0)
|
||||||
|
mem_free = meminfo_raw.get("MemFree", 0)
|
||||||
|
mem_available = meminfo_raw.get("MemAvailable", mem_free)
|
||||||
|
mem_buffers = meminfo_raw.get("Buffers", 0)
|
||||||
|
mem_cached = meminfo_raw.get("Cached", 0)
|
||||||
|
mem_used = mem_total - mem_free - mem_buffers - mem_cached
|
||||||
|
if mem_used < 0:
|
||||||
|
mem_used = mem_total - mem_available
|
||||||
|
|
||||||
|
# Swap
|
||||||
|
swap_total = meminfo_raw.get("SwapTotal", 0)
|
||||||
|
swap_free = meminfo_raw.get("SwapFree", 0)
|
||||||
|
swap_used = swap_total - swap_free
|
||||||
|
|
||||||
|
# Network traffic from /sys/class/net/<iface>/statistics/
|
||||||
|
traffic: dict[str, dict[str, int]] = {}
|
||||||
|
try:
|
||||||
|
net_root = Path("/sys/class/net")
|
||||||
|
if net_root.is_dir():
|
||||||
|
for iface_dir in net_root.iterdir():
|
||||||
|
stats_dir = iface_dir / "statistics"
|
||||||
|
if not stats_dir.is_dir():
|
||||||
|
continue
|
||||||
|
iface_name = iface_dir.name
|
||||||
|
rx_bytes = 0
|
||||||
|
tx_bytes = 0
|
||||||
|
rx_packets = 0
|
||||||
|
tx_packets = 0
|
||||||
|
try:
|
||||||
|
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
|
||||||
|
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
|
||||||
|
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
|
||||||
|
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
traffic[iface_name] = {
|
||||||
|
"rx_bytes": rx_bytes,
|
||||||
|
"tx_bytes": tx_bytes,
|
||||||
|
"rx_packets": rx_packets,
|
||||||
|
"tx_packets": tx_packets,
|
||||||
|
}
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"load": {
|
||||||
|
"load1": loads[0],
|
||||||
|
"load5": loads[1],
|
||||||
|
"load15": loads[2],
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"total": mem_total,
|
||||||
|
"available": mem_available,
|
||||||
|
"used": mem_used,
|
||||||
|
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
|
||||||
|
},
|
||||||
|
"swap": {
|
||||||
|
"total": swap_total,
|
||||||
|
"used": swap_used,
|
||||||
|
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
|
||||||
|
},
|
||||||
|
"traffic": traffic,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("system", _collect_system)
|
||||||
|
register_volatile(
|
||||||
|
"system",
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"load",
|
||||||
|
"memory",
|
||||||
|
"swap",
|
||||||
|
"traffic",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""WireGuard state collector."""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib import schema
|
||||||
|
from lib.common import compute_pending, run_proc, strip_apply_meta
|
||||||
|
from lib.state import _now_iso, register_collector, register_volatile
|
||||||
|
from lib.wireguard import DEFAULT_CONFIG, get_config, parse_wg_show_output
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_wireguard() -> schema.WgState:
|
||||||
|
"""Collect WireGuard config, per-class status, and peers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing interface config, per-class runtime status,
|
||||||
|
combined peers, and overall tunnel status.
|
||||||
|
"""
|
||||||
|
# Load config via lib.wireguard defaults (which include the built-in
|
||||||
|
# full/internet access classes).
|
||||||
|
try:
|
||||||
|
cfg = get_config()
|
||||||
|
except Exception:
|
||||||
|
cfg = deepcopy(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
pending_changes, pending_diff = compute_pending(cfg)
|
||||||
|
|
||||||
|
# Safe config (strip private keys from interface and access classes)
|
||||||
|
safe = strip_apply_meta(cfg)
|
||||||
|
if "interface" in safe:
|
||||||
|
safe["interface"] = dict(safe["interface"])
|
||||||
|
safe["interface"].pop("private_key", None)
|
||||||
|
if "access_classes" in safe:
|
||||||
|
safe["access_classes"] = {}
|
||||||
|
for ck, cv in cfg.get("access_classes", {}).items():
|
||||||
|
if isinstance(cv, dict):
|
||||||
|
entry = dict(cv)
|
||||||
|
entry.pop("private_key", None)
|
||||||
|
safe["access_classes"][ck] = entry
|
||||||
|
|
||||||
|
# Peers list (safe)
|
||||||
|
peers: list[dict[str, Any]] = []
|
||||||
|
for name, info in cfg.get("peers", {}).items():
|
||||||
|
entry = dict(info)
|
||||||
|
entry["name"] = name
|
||||||
|
entry.pop("private_key", None)
|
||||||
|
peers.append(entry)
|
||||||
|
|
||||||
|
# Runtime status — per-class interfaces
|
||||||
|
status: dict[str, Any] = {
|
||||||
|
"up": False,
|
||||||
|
"interface": {},
|
||||||
|
"peers": [],
|
||||||
|
"classes": {},
|
||||||
|
}
|
||||||
|
classes = cfg.get("access_classes", {})
|
||||||
|
any_up = False
|
||||||
|
|
||||||
|
for class_key in classes:
|
||||||
|
class_cfg = classes.get(class_key)
|
||||||
|
if not isinstance(class_cfg, dict):
|
||||||
|
continue
|
||||||
|
ifname = f"wg-{class_key}"
|
||||||
|
try:
|
||||||
|
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||||
|
if res.returncode != 0:
|
||||||
|
status["classes"][class_key] = {
|
||||||
|
"up": False,
|
||||||
|
"interface": {},
|
||||||
|
"peers": [],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
parsed = parse_wg_show_output(res.stdout.strip())
|
||||||
|
status["classes"][class_key] = {
|
||||||
|
"up": parsed["up"],
|
||||||
|
"interface": parsed.get("interface", {}),
|
||||||
|
"peers": parsed.get("peers", []),
|
||||||
|
}
|
||||||
|
if parsed["up"]:
|
||||||
|
any_up = True
|
||||||
|
except Exception:
|
||||||
|
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
|
||||||
|
|
||||||
|
# Also collect legacy single-interface status
|
||||||
|
try:
|
||||||
|
ifname = cfg["interface"].get("name", "wg0")
|
||||||
|
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||||
|
if res.returncode == 0:
|
||||||
|
parsed = parse_wg_show_output(res.stdout.strip())
|
||||||
|
status["up"] = True
|
||||||
|
status["interface"] = parsed.get("interface", {})
|
||||||
|
status["peers"] = parsed.get("peers", [])
|
||||||
|
any_up = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if any_up:
|
||||||
|
status["up"] = True
|
||||||
|
|
||||||
|
status["pending_changes"] = pending_changes
|
||||||
|
# Drop any private-key paths so the pending summary never exposes
|
||||||
|
# key material.
|
||||||
|
status["pending_diff"] = [d for d in pending_diff if "private_key" not in d["path"]]
|
||||||
|
return {
|
||||||
|
"config": safe,
|
||||||
|
"status": status,
|
||||||
|
"peers": peers,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("wireguard", _collect_wireguard)
|
||||||
|
register_volatile(
|
||||||
|
"wireguard",
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"status.peers[].transfer_received",
|
||||||
|
"status.peers[].transfer_sent",
|
||||||
|
"status.peers[].latest_handshake",
|
||||||
|
"status.classes[].peers[].transfer_received",
|
||||||
|
"status.classes[].peers[].transfer_sent",
|
||||||
|
"status.classes[].peers[].latest_handshake",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -592,7 +592,7 @@ def _check_acme_account() -> tuple[bool, str]:
|
|||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
from lib.state import _parse_account_conf
|
from daemon.collectors.acme import _parse_account_conf
|
||||||
|
|
||||||
info = _parse_account_conf(_ACME_HOME)
|
info = _parse_account_conf(_ACME_HOME)
|
||||||
if info.get("registered"):
|
if info.get("registered"):
|
||||||
@@ -607,11 +607,11 @@ def _check_acme_account() -> tuple[bool, str]:
|
|||||||
def _check_account_registered() -> tuple[bool, str]:
|
def _check_account_registered() -> tuple[bool, str]:
|
||||||
"""Blocking check: verify an ACME account is registered.
|
"""Blocking check: verify an ACME account is registered.
|
||||||
|
|
||||||
Delegates to ``lib.state._parse_account_conf()`` which checks both
|
Delegates to ``daemon.collectors.acme._parse_account_conf()`` which checks both
|
||||||
the legacy .account.conf and the declarative config/acme/config.json
|
the legacy .account.conf and the declarative config/acme/config.json
|
||||||
used by modern acme.sh (v3.x).
|
used by modern acme.sh (v3.x).
|
||||||
"""
|
"""
|
||||||
from lib.state import _parse_account_conf
|
from daemon.collectors.acme import _parse_account_conf
|
||||||
|
|
||||||
info = _parse_account_conf(_ACME_HOME)
|
info = _parse_account_conf(_ACME_HOME)
|
||||||
if info.get("registered"):
|
if info.get("registered"):
|
||||||
@@ -622,10 +622,10 @@ def _check_account_registered() -> tuple[bool, str]:
|
|||||||
def _get_account_info() -> dict[str, Any]:
|
def _get_account_info() -> dict[str, Any]:
|
||||||
"""Read and return the ACME account info dict.
|
"""Read and return the ACME account info dict.
|
||||||
|
|
||||||
Delegates to ``lib.state._parse_account_conf()`` for a single
|
Delegates to ``daemon.collectors.acme._parse_account_conf()`` for a single
|
||||||
source of truth.
|
source of truth.
|
||||||
"""
|
"""
|
||||||
from lib.state import _parse_account_conf
|
from daemon.collectors.acme import _parse_account_conf
|
||||||
|
|
||||||
return _parse_account_conf(_ACME_HOME)
|
return _parse_account_conf(_ACME_HOME)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Shared helpers for daemon handlers."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from daemon.server import refresh_state
|
||||||
|
from lib.sync import SyncEvent, bus
|
||||||
|
|
||||||
|
|
||||||
|
def emit_and_refresh(
|
||||||
|
subsystem: str, payload: dict[str, Any] | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Emit a ``config_saved`` sync event and refresh the affected state.
|
||||||
|
|
||||||
|
All mutation handlers end with the same tail: emit the event, refresh
|
||||||
|
the source subsystem plus every subsystem the sync touched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subsystem: Source subsystem name.
|
||||||
|
payload: Event payload (e.g. ``{"action": "zone_created"}``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Subsystems affected by the sync event.
|
||||||
|
"""
|
||||||
|
sync_result = bus.emit(SyncEvent(subsystem, "config_saved", payload or {}))
|
||||||
|
refresh_state([subsystem, *sync_result.affected_subsystems])
|
||||||
|
return sync_result.affected_subsystems
|
||||||
+16
-75
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from daemon.handlers.common import emit_and_refresh
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||||
@@ -24,7 +25,7 @@ from daemon.iface import (
|
|||||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||||
POST_DNSMASQ_UPSTREAMS,
|
POST_DNSMASQ_UPSTREAMS,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import NotFoundError, registry
|
||||||
from lib.common import (
|
from lib.common import (
|
||||||
deep_merge,
|
deep_merge,
|
||||||
ensure_dirs,
|
ensure_dirs,
|
||||||
@@ -35,7 +36,6 @@ from lib.common import (
|
|||||||
stamp_applied,
|
stamp_applied,
|
||||||
strip_apply_meta,
|
strip_apply_meta,
|
||||||
)
|
)
|
||||||
from lib.sync import SyncEvent, bus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -152,10 +152,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
_save_config(body)
|
_save_config(body)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "config_saved"})
|
||||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"config_saved": True}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -171,10 +168,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
current = _get_config()
|
current = _get_config()
|
||||||
merged = deep_merge(current, body)
|
merged = deep_merge(current, body)
|
||||||
_save_config(merged)
|
_save_config(merged)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "config_patched"})
|
||||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"config_saved": True}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -201,11 +195,8 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
cfg_after = _get_config()
|
cfg_after = _get_config()
|
||||||
stamp_applied(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
_save_config(cfg_after)
|
_save_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
synced = emit_and_refresh("dnsmasq", {"action": "config_applied"})
|
||||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
|
return {"applied": True, "synced": synced}
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"applied": True, "synced": sync_result.affected_subsystems}
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register(GET_DNSMASQ_STATUS)
|
@registry.register(GET_DNSMASQ_STATUS)
|
||||||
@@ -281,12 +272,7 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
|||||||
entry["dns"] = body["dns"]
|
entry["dns"] = body["dns"]
|
||||||
ranges.append(entry)
|
ranges.append(entry)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "range_added", "interface": iface})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"interface": iface, "start": start, "end": end}
|
return {"interface": iface, "start": start, "end": end}
|
||||||
|
|
||||||
|
|
||||||
@@ -321,12 +307,7 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
|||||||
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
|
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
|
||||||
)
|
)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "range_removed", "interface": iface})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"interface": iface, "start": start, "end": end}
|
return {"interface": iface, "start": start, "end": end}
|
||||||
|
|
||||||
|
|
||||||
@@ -365,26 +346,14 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
if hostname is not None:
|
if hostname is not None:
|
||||||
leases[i]["hostname"] = hostname
|
leases[i]["hostname"] = hostname
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq",
|
|
||||||
"config_saved",
|
|
||||||
{"action": "static_lease_added", "mac": mac},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||||
if hostname:
|
if hostname:
|
||||||
entry["hostname"] = hostname
|
entry["hostname"] = hostname
|
||||||
leases.append(entry)
|
leases.append(entry)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||||
|
|
||||||
|
|
||||||
@@ -409,12 +378,7 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
if len(cfg["dhcp"]["static_leases"]) == before:
|
if len(cfg["dhcp"]["static_leases"]) == before:
|
||||||
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
|
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "static_lease_removed", "mac": mac})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"mac": mac}
|
return {"mac": mac}
|
||||||
|
|
||||||
|
|
||||||
@@ -440,26 +404,14 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
|||||||
if hostname is not None:
|
if hostname is not None:
|
||||||
records[i]["hostname"] = hostname
|
records[i]["hostname"] = hostname
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq",
|
|
||||||
"config_saved",
|
|
||||||
{"action": "dns_record_added", "name": name},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"name": name, "address": address, "hostname": hostname}
|
return {"name": name, "address": address, "hostname": hostname}
|
||||||
entry: dict[str, Any] = {"name": name, "address": address}
|
entry: dict[str, Any] = {"name": name, "address": address}
|
||||||
if hostname:
|
if hostname:
|
||||||
entry["hostname"] = hostname
|
entry["hostname"] = hostname
|
||||||
records.append(entry)
|
records.append(entry)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"name": name, "address": address, "hostname": hostname}
|
return {"name": name, "address": address, "hostname": hostname}
|
||||||
|
|
||||||
|
|
||||||
@@ -482,12 +434,7 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
|||||||
if len(cfg["dns"]["custom_records"]) == before:
|
if len(cfg["dns"]["custom_records"]) == before:
|
||||||
raise NotFoundError(f"DNS record '{name}' not found")
|
raise NotFoundError(f"DNS record '{name}' not found")
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "dns_record_removed", "name": name})
|
||||||
SyncEvent(
|
|
||||||
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"name": name}
|
return {"name": name}
|
||||||
|
|
||||||
|
|
||||||
@@ -503,10 +450,7 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
cfg["dns"]["upstreams"] = list(body["servers"])
|
cfg["dns"]["upstreams"] = list(body["servers"])
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "upstreams_set"})
|
||||||
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"upstreams": cfg["dns"]["upstreams"]}
|
return {"upstreams": cfg["dns"]["upstreams"]}
|
||||||
|
|
||||||
|
|
||||||
@@ -523,8 +467,5 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
cfg["dns"]["domain"] = domain if domain else None
|
cfg["dns"]["domain"] = domain if domain else None
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("dnsmasq", {"action": "domain_set"})
|
||||||
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
|
|
||||||
)
|
|
||||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
|
||||||
return {"domain": cfg["dns"]["domain"]}
|
return {"domain": cfg["dns"]["domain"]}
|
||||||
|
|||||||
+79
-108
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from daemon.handlers.common import emit_and_refresh
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||||
@@ -33,7 +34,7 @@ from daemon.iface import (
|
|||||||
POST_FIREWALL_ZONES_INTERFACES,
|
POST_FIREWALL_ZONES_INTERFACES,
|
||||||
POST_FIREWALL_ZONES_SERVICES,
|
POST_FIREWALL_ZONES_SERVICES,
|
||||||
)
|
)
|
||||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
from daemon.server import ConflictError, NotFoundError, registry
|
||||||
from lib import network
|
from lib import network
|
||||||
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
|
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
@@ -43,11 +44,11 @@ from lib.firewall import (
|
|||||||
_parse_all_zones_output,
|
_parse_all_zones_output,
|
||||||
_parse_zone_output,
|
_parse_zone_output,
|
||||||
fw_change_summary,
|
fw_change_summary,
|
||||||
|
validate_coverage,
|
||||||
)
|
)
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
save_backup as _save_backup,
|
save_backup as _save_backup,
|
||||||
)
|
)
|
||||||
from lib.sync import SyncEvent, bus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -83,6 +84,31 @@ def _save_config(cfg: dict[str, Any]) -> None:
|
|||||||
save_json(CONFIG_FILE, cfg, indent=2)
|
save_json(CONFIG_FILE, cfg, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_coverage(cfg: dict[str, Any]) -> None:
|
||||||
|
"""Reject a config that leaves a managed interface without coverage.
|
||||||
|
|
||||||
|
Runs the pure ``validate_coverage`` invariant against the current
|
||||||
|
network config. ``lo`` and ``wg*`` are exempt, and interfaces declared
|
||||||
|
in the top-level ``unmanaged`` list are exempt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: The (merged or full) firewall config dict to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a network-managed interface is not covered by any
|
||||||
|
zone and is not declared under ``unmanaged``.
|
||||||
|
"""
|
||||||
|
uncovered = validate_coverage(cfg, network.get_config())
|
||||||
|
if uncovered:
|
||||||
|
raise ValueError(
|
||||||
|
"Refusing to save: "
|
||||||
|
f"{', '.join(repr(n) for n in uncovered)} "
|
||||||
|
f"have no firewall zone coverage and are not declared in the "
|
||||||
|
f"'unmanaged' list. Assign each interface to a zone, or add it "
|
||||||
|
f"to the top-level 'unmanaged' list."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reload() -> None:
|
def _reload() -> None:
|
||||||
"""Reload firewalld to apply permanent changes."""
|
"""Reload firewalld to apply permanent changes."""
|
||||||
run(["firewall-cmd", "--reload"], sudo=True)
|
run(["firewall-cmd", "--reload"], sudo=True)
|
||||||
@@ -150,11 +176,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
|||||||
|
|
||||||
- the config would strip both https and ssh from the default zone
|
- the config would strip both https and ssh from the default zone
|
||||||
(management lockout);
|
(management lockout);
|
||||||
- a network-subsystem-managed interface would end up with no firewall
|
- the config leaves a network-subsystem-managed interface with no
|
||||||
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
|
firewall zone coverage (``lo`` and ``wg*`` interfaces are excluded).
|
||||||
Zones whose config omits the ``interfaces`` key are left hands-off, so
|
The config is the source of truth for zone interfaces — an absent
|
||||||
their current live interfaces count as coverage, as do the live
|
``interfaces`` key counts as empty — so coverage is computed from the
|
||||||
interfaces of zones that are live but absent from the config.
|
config alone via ``validate_coverage`` with no live-state fallback.
|
||||||
|
Interfaces listed in the top-level ``unmanaged`` key are exempt. The
|
||||||
|
same invariant is enforced at save time (POST/PATCH /firewall/config),
|
||||||
|
so a conflict here means the network config changed after the firewall
|
||||||
|
config was saved (e.g. a new interface no zone covers).
|
||||||
"""
|
"""
|
||||||
from lib.firewall import get_config as _get_lib_config
|
from lib.firewall import get_config as _get_lib_config
|
||||||
|
|
||||||
@@ -178,37 +208,20 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
|||||||
f'to the zone\'s services, or pass {{"force": true}}.'
|
f'to the zone\'s services, or pass {{"force": true}}.'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Coverage guard: after apply, every network-managed interface must
|
# Coverage invariant: every network-managed interface must be
|
||||||
# belong to a zone or traffic (and DHCP) on that segment is dropped.
|
# covered by a zone in the config (or declared unmanaged), or
|
||||||
live_active = _parse_active_zones(
|
# traffic (and DHCP) on that segment is dropped. Pure config check
|
||||||
run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
# — the config is the source of truth, so no live-state comparison.
|
||||||
)
|
uncovered = validate_coverage(cfg, network.get_config())
|
||||||
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:
|
if uncovered:
|
||||||
raise ConflictError(
|
raise ConflictError(
|
||||||
"Refusing to apply: "
|
"Refusing to apply: "
|
||||||
f"{', '.join(repr(n) for n in uncovered)} "
|
f"{', '.join(repr(n) for n in uncovered)} "
|
||||||
f"would have no firewall zone coverage after apply, so all "
|
f"have no firewall zone coverage in the config and are not "
|
||||||
f"traffic (including DHCP) from those segments would be "
|
f"declared unmanaged, so all traffic (including DHCP) from "
|
||||||
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
|
f"those segments would be dropped. Assign each interface to "
|
||||||
|
f"a zone (or list it under the config's top-level 'unmanaged' "
|
||||||
|
f'key), or pass {{"force": true}}.'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
|
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
|
||||||
@@ -297,16 +310,14 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Reconcile interfaces — same remove-then-add pattern.
|
# Step 3: Reconcile interfaces — same remove-then-add pattern.
|
||||||
# Absent "interfaces" key = hands off (keep the zone's live
|
# The config is the source of truth: an absent "interfaces" key
|
||||||
# interfaces); an explicit empty list = intentional unassign-all.
|
# counts as an empty list (unassign-all), matching the coverage
|
||||||
if "interfaces" in zone_cfg:
|
# invariant and the pending diff.
|
||||||
current_ifaces: list[str] = []
|
current_ifaces: list[str] = []
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
current_ifaces = _parse_zone_output(
|
current_ifaces = _parse_zone_output(
|
||||||
zone_name,
|
zone_name,
|
||||||
run(
|
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||||
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
|
|
||||||
),
|
|
||||||
).get("interfaces", [])
|
).get("interfaces", [])
|
||||||
for iface in current_ifaces:
|
for iface in current_ifaces:
|
||||||
run(
|
run(
|
||||||
@@ -576,19 +587,20 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
Dict with ``config_saved`` flag set to ``True``.
|
Dict with ``config_saved`` flag set to ``True``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If body is empty, missing ``zones`` key,
|
ValueError: If body is empty, missing ``zones`` key, ``zones`` is
|
||||||
or ``zones`` is not a dict.
|
not a dict, ``unmanaged`` is not a list, or the config leaves a
|
||||||
|
network-managed interface without zone coverage.
|
||||||
"""
|
"""
|
||||||
if not body or "zones" not in body:
|
if not body or "zones" not in body:
|
||||||
raise ValueError("'zones' key is required")
|
raise ValueError("'zones' key is required")
|
||||||
if not isinstance(body["zones"], dict):
|
if not isinstance(body["zones"], dict):
|
||||||
raise ValueError("'zones' must be a dict")
|
raise ValueError("'zones' must be a dict")
|
||||||
|
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
|
||||||
|
raise ValueError("'unmanaged' must be a list")
|
||||||
|
_check_coverage(body)
|
||||||
_save_config(body)
|
_save_config(body)
|
||||||
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "config_saved"})
|
||||||
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"config_saved": True}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -604,20 +616,22 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
Dict with ``config_saved`` flag set to ``True``.
|
Dict with ``config_saved`` flag set to ``True``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If body is empty.
|
ValueError: If body is empty, ``unmanaged`` is not a list, or the
|
||||||
|
merged config leaves a network-managed interface without zone
|
||||||
|
coverage.
|
||||||
"""
|
"""
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body must be a JSON object")
|
raise ValueError("Request body must be a JSON object")
|
||||||
|
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
|
||||||
|
raise ValueError("'unmanaged' must be a list")
|
||||||
from lib.common import deep_merge
|
from lib.common import deep_merge
|
||||||
|
|
||||||
current = _get_config()
|
current = _get_config()
|
||||||
merged = deep_merge(current, body)
|
merged = deep_merge(current, body)
|
||||||
|
_check_coverage(merged)
|
||||||
_save_config(merged)
|
_save_config(merged)
|
||||||
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "config_patched"})
|
||||||
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"config_saved": True}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -663,17 +677,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ConflictError: If the config would strip both https and ssh from the
|
ConflictError: If the config would strip both https and ssh from the
|
||||||
default zone, or would leave a network-managed interface without
|
default zone, or would remove zone coverage from a
|
||||||
zone coverage, and ``force`` is not set.
|
network-managed interface that is covered now, and ``force`` is
|
||||||
|
not set.
|
||||||
"""
|
"""
|
||||||
force = bool(_body and _body.get("force"))
|
force = bool(_body and _body.get("force"))
|
||||||
result = _config_apply(force=force)
|
result = _config_apply(force=force)
|
||||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||||
sync_result = bus.emit(
|
synced = emit_and_refresh("firewall", {"action": "config_applied"})
|
||||||
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
|
result["synced"] = synced
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
result["synced"] = sync_result.affected_subsystems
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -721,12 +733,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
_reload()
|
_reload()
|
||||||
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "zone_created", "zone": zone_name})
|
||||||
SyncEvent(
|
|
||||||
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone_name}
|
return {"zone": zone_name}
|
||||||
|
|
||||||
|
|
||||||
@@ -754,10 +761,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
|
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
|
||||||
_reload()
|
_reload()
|
||||||
logger.info("Zone '%s' deleted", zone)
|
logger.info("Zone '%s' deleted", zone)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "zone_deleted", "zone": zone})
|
||||||
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone}
|
return {"zone": zone}
|
||||||
|
|
||||||
|
|
||||||
@@ -862,12 +866,7 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
|
|
||||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "interfaces_set", "zone": zone})
|
||||||
SyncEvent(
|
|
||||||
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone, "interfaces": interfaces}
|
return {"zone": zone, "interfaces": interfaces}
|
||||||
|
|
||||||
|
|
||||||
@@ -938,10 +937,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
|||||||
stamp_applied(cfg)
|
stamp_applied(cfg)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
logger.info("Zone '%s' services set to %s", zone, services)
|
logger.info("Zone '%s' services set to %s", zone, services)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "services_set", "zone": zone})
|
||||||
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone, "services": services}
|
return {"zone": zone, "services": services}
|
||||||
|
|
||||||
|
|
||||||
@@ -987,12 +983,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
cfg["zones"][zone]["rich_rules"].append(entry)
|
cfg["zones"][zone]["rich_rules"].append(entry)
|
||||||
stamp_applied(cfg)
|
stamp_applied(cfg)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "rich_rule_added", "zone": zone})
|
||||||
SyncEvent(
|
|
||||||
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone, "id": rule_id, "rule": rule}
|
return {"zone": zone, "id": rule_id, "rule": rule}
|
||||||
|
|
||||||
|
|
||||||
@@ -1044,12 +1035,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
]
|
]
|
||||||
stamp_applied(cfg)
|
stamp_applied(cfg)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "rich_rule_removed", "zone": zone})
|
||||||
SyncEvent(
|
|
||||||
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone, "id": rule_id}
|
return {"zone": zone, "id": rule_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -1130,12 +1116,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
|||||||
zone_cfg["masquerade"] = bool(enable)
|
zone_cfg["masquerade"] = bool(enable)
|
||||||
stamp_applied(cfg)
|
stamp_applied(cfg)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone})
|
||||||
SyncEvent(
|
|
||||||
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone, "masquerade": bool(enable)}
|
return {"zone": zone, "masquerade": bool(enable)}
|
||||||
|
|
||||||
|
|
||||||
@@ -1192,12 +1173,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
cfg["zones"][zone]["forward_ports"].append(entry)
|
cfg["zones"][zone]["forward_ports"].append(entry)
|
||||||
stamp_applied(cfg)
|
stamp_applied(cfg)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "forward_port_added", "zone": zone})
|
||||||
SyncEvent(
|
|
||||||
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
||||||
|
|
||||||
|
|
||||||
@@ -1258,12 +1234,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
]
|
]
|
||||||
stamp_applied(cfg)
|
stamp_applied(cfg)
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("firewall", {"action": "forward_port_removed", "zone": zone})
|
||||||
SyncEvent(
|
|
||||||
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
||||||
return {"zone": zone, "port": int(port), "proto": proto}
|
return {"zone": zone, "port": int(port), "proto": proto}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from daemon.handlers.common import emit_and_refresh
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
GET_NETWORK_INFER_DHCP_RANGES,
|
GET_NETWORK_INFER_DHCP_RANGES,
|
||||||
GET_NETWORK_INFER_ZONES,
|
GET_NETWORK_INFER_ZONES,
|
||||||
@@ -20,7 +21,7 @@ from daemon.iface import (
|
|||||||
POST_NETWORK_INTERFACE_RELOAD,
|
POST_NETWORK_INTERFACE_RELOAD,
|
||||||
POST_NETWORK_SYSCTL_SET,
|
POST_NETWORK_SYSCTL_SET,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import NotFoundError, registry
|
||||||
from lib.common import run, stamp_applied, validate_interface_name
|
from lib.common import run, stamp_applied, validate_interface_name
|
||||||
from lib.dnsmasq import get_config as _get_dm_cfg
|
from lib.dnsmasq import get_config as _get_dm_cfg
|
||||||
from lib.dnsmasq import save_config as _save_dm_cfg
|
from lib.dnsmasq import save_config as _save_dm_cfg
|
||||||
@@ -36,7 +37,6 @@ from lib.network import (
|
|||||||
render_network_file,
|
render_network_file,
|
||||||
save_config,
|
save_config,
|
||||||
)
|
)
|
||||||
from lib.sync import SyncEvent, bus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -220,16 +220,13 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
|||||||
cfg_after = get_config()
|
cfg_after = get_config()
|
||||||
stamp_applied(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
save_config(cfg_after)
|
save_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
synced = emit_and_refresh(
|
||||||
SyncEvent(
|
"networkd", {"action": "interface_saved", "interface": name}
|
||||||
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"applied": deployed,
|
"applied": deployed,
|
||||||
"synced": sync_result.affected_subsystems,
|
"synced": synced,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -298,10 +295,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
cfg_after = get_config()
|
cfg_after = get_config()
|
||||||
stamp_applied(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
save_config(cfg_after)
|
save_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
synced = emit_and_refresh("networkd", {"action": "config_applied"})
|
||||||
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
|
|
||||||
)
|
|
||||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Network config applied: %d interfaces, %d stale cleaned",
|
"Network config applied: %d interfaces, %d stale cleaned",
|
||||||
@@ -312,7 +306,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
"applied": len(generated),
|
"applied": len(generated),
|
||||||
"files": [str(p) for p in generated],
|
"files": [str(p) for p in generated],
|
||||||
"cleaned": [str(p) for p in cleaned],
|
"cleaned": [str(p) for p in cleaned],
|
||||||
"synced": sync_result.affected_subsystems,
|
"synced": synced,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -366,8 +360,5 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info("sysctl %s set to %s", name, value)
|
logger.info("sysctl %s set to %s", name, value)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("networkd", {"action": "sysctl_set", "name": name})
|
||||||
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
|
|
||||||
)
|
|
||||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
|
||||||
return {"name": name, "value": value}
|
return {"name": name, "value": value}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from daemon.handlers.common import emit_and_refresh
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_WIREGUARD_CLASSES,
|
DELETE_WIREGUARD_CLASSES,
|
||||||
DELETE_WIREGUARD_CLASSES_DOWN,
|
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||||
@@ -27,9 +28,8 @@ from daemon.iface import (
|
|||||||
POST_WIREGUARD_INITIALIZE,
|
POST_WIREGUARD_INITIALIZE,
|
||||||
POST_WIREGUARD_PEERS_ADD,
|
POST_WIREGUARD_PEERS_ADD,
|
||||||
)
|
)
|
||||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
from daemon.server import ConflictError, NotFoundError, registry
|
||||||
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
|
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
|
||||||
from lib.sync import SyncEvent, bus
|
|
||||||
from lib.wireguard import (
|
from lib.wireguard import (
|
||||||
_class_interface_name,
|
_class_interface_name,
|
||||||
_class_peers,
|
_class_peers,
|
||||||
@@ -122,10 +122,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
body["access_classes"] = current.get("access_classes", {})
|
body["access_classes"] = current.get("access_classes", {})
|
||||||
|
|
||||||
_save_wireguard_config(body)
|
_save_wireguard_config(body)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "config_saved"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {"config_saved": True}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -153,10 +150,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
current = _get_wireguard_config()
|
current = _get_wireguard_config()
|
||||||
merged = deep_merge(current, body)
|
merged = deep_merge(current, body)
|
||||||
_save_wireguard_config(merged)
|
_save_wireguard_config(merged)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "config_patched"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {"config_saved": True}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -217,13 +211,10 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
cfg_after = _get_wireguard_config()
|
cfg_after = _get_wireguard_config()
|
||||||
stamp_applied(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
_save_wireguard_config(cfg_after)
|
_save_wireguard_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
synced = emit_and_refresh("wireguard", {"action": "config_applied"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {
|
return {
|
||||||
"applied": True,
|
"applied": True,
|
||||||
"synced": sync_result.affected_subsystems,
|
"synced": synced,
|
||||||
"interfaces": affected,
|
"interfaces": affected,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +246,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "tunnel_down"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {"down": True}
|
return {"down": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -296,12 +284,7 @@ def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
local_tmp.unlink(missing_ok=True)
|
local_tmp.unlink(missing_ok=True)
|
||||||
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
|
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
|
||||||
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
|
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "class_up", "class_key": class_key})
|
||||||
SyncEvent(
|
|
||||||
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {"up": True, "interface": ifname}
|
return {"up": True, "interface": ifname}
|
||||||
|
|
||||||
|
|
||||||
@@ -328,14 +311,7 @@ def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
|
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "class_down", "class_key": class_key})
|
||||||
SyncEvent(
|
|
||||||
"wireguard",
|
|
||||||
"config_saved",
|
|
||||||
{"action": "class_down", "class_key": class_key},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {"down": True, "interface": ifname}
|
return {"down": True, "interface": ifname}
|
||||||
|
|
||||||
|
|
||||||
@@ -377,10 +353,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
_save_wireguard_config(cfg)
|
_save_wireguard_config(cfg)
|
||||||
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "initialized"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
|
|
||||||
safe = dict(cfg)
|
safe = dict(cfg)
|
||||||
safe["interface"] = dict(safe["interface"])
|
safe["interface"] = dict(safe["interface"])
|
||||||
@@ -467,12 +440,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
logger.info("WireGuard peer '%s' added", name)
|
logger.info("WireGuard peer '%s' added", name)
|
||||||
_peer_action = "peer_added"
|
_peer_action = "peer_added"
|
||||||
_save_wireguard_config(cfg)
|
_save_wireguard_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": _peer_action, "peer_name": name})
|
||||||
SyncEvent(
|
|
||||||
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
peer_out = dict(peers[name])
|
peer_out = dict(peers[name])
|
||||||
peer_out.pop("private_key", None)
|
peer_out.pop("private_key", None)
|
||||||
return peer_out
|
return peer_out
|
||||||
@@ -498,12 +466,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
del peers[name]
|
del peers[name]
|
||||||
_save_wireguard_config(cfg)
|
_save_wireguard_config(cfg)
|
||||||
logger.info("WireGuard peer '%s' removed", name)
|
logger.info("WireGuard peer '%s' removed", name)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "peer_removed", "peer_name": name})
|
||||||
SyncEvent(
|
|
||||||
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {"name": name}
|
return {"name": name}
|
||||||
|
|
||||||
|
|
||||||
@@ -629,10 +592,7 @@ def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
"public_key": "",
|
"public_key": "",
|
||||||
}
|
}
|
||||||
_save_wireguard_config(cfg)
|
_save_wireguard_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "class_created"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
out = dict(classes[key])
|
out = dict(classes[key])
|
||||||
out.pop("private_key", None)
|
out.pop("private_key", None)
|
||||||
return out
|
return out
|
||||||
@@ -660,10 +620,7 @@ def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
if field in body:
|
if field in body:
|
||||||
class_cfg[field] = body[field]
|
class_cfg[field] = body[field]
|
||||||
_save_wireguard_config(cfg)
|
_save_wireguard_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "class_updated"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
out = dict(classes[key])
|
out = dict(classes[key])
|
||||||
out.pop("private_key", None)
|
out.pop("private_key", None)
|
||||||
return {"key": key, **out}
|
return {"key": key, **out}
|
||||||
@@ -699,8 +656,5 @@ def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
del classes[key]
|
del classes[key]
|
||||||
_save_wireguard_config(cfg)
|
_save_wireguard_config(cfg)
|
||||||
sync_result = bus.emit(
|
emit_and_refresh("wireguard", {"action": "class_deleted"})
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
|
|
||||||
)
|
|
||||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
|
||||||
return {"key": key}
|
return {"key": key}
|
||||||
|
|||||||
+16
-15
@@ -18,6 +18,7 @@ from typing import Any
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
import daemon.collectors # noqa: F401 (registers state collectors)
|
||||||
from daemon.iface import PathLike
|
from daemon.iface import PathLike
|
||||||
from lib.auth import blacklist_expired
|
from lib.auth import blacklist_expired
|
||||||
from lib.state import _DEFAULT_POLL_INTERVALS
|
from lib.state import _DEFAULT_POLL_INTERVALS
|
||||||
@@ -145,14 +146,18 @@ class Registry:
|
|||||||
registry = Registry()
|
registry = Registry()
|
||||||
|
|
||||||
|
|
||||||
def refresh_state(subsystems: list[str] | None = None) -> None:
|
def refresh_state(subsystems: list[str] | None = None, bump: bool = True) -> None:
|
||||||
"""Refresh the pre-computed state for the given subsystems (or all).
|
"""Refresh the pre-computed state for the given subsystems (or all).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
|
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
|
||||||
|
bump: Bump the version counter for each refreshed subsystem.
|
||||||
|
``refresh_status`` passes ``False`` — versions advance on
|
||||||
|
structural poll diffs and on mutation-triggered refreshes only.
|
||||||
"""
|
"""
|
||||||
state_store.populate(subsystems)
|
state_store.populate(subsystems)
|
||||||
targets = subsystems or state_store.SUBSYSTEMS
|
targets = subsystems or state_store.SUBSYSTEMS
|
||||||
|
if bump:
|
||||||
for name in targets:
|
for name in targets:
|
||||||
state_store.bump(name)
|
state_store.bump(name)
|
||||||
try:
|
try:
|
||||||
@@ -600,22 +605,11 @@ async def refresh_status(_request: web.Request) -> web.Response:
|
|||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
body = None
|
body = None
|
||||||
subsystems = body.get("subsystems") if body else None
|
subsystems = body.get("subsystems") if body else None
|
||||||
state_store.populate(subsystems)
|
|
||||||
targets = subsystems or state_store.SUBSYSTEMS
|
|
||||||
snapshot = {name: state_store.get(name) for name in targets}
|
|
||||||
|
|
||||||
# Broadcast to all WS clients (fire-and-forget, gather for parallelism).
|
|
||||||
# Deliberately no version bump — versions advance on structural poll
|
# Deliberately no version bump — versions advance on structural poll
|
||||||
# diffs and on refresh_state() only.
|
# diffs and on refresh_state() only.
|
||||||
async def _broadcast_all():
|
refresh_state(subsystems, bump=False)
|
||||||
await asyncio.gather(
|
targets = subsystems or state_store.SUBSYSTEMS
|
||||||
*[broadcast_versions(name) for name in targets],
|
snapshot = {name: state_store.get(name) for name in targets}
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
task = asyncio.create_task(_broadcast_all())
|
|
||||||
task.add_done_callback(_ws_tasks.discard)
|
|
||||||
_ws_tasks.add(task)
|
|
||||||
return ok(snapshot)
|
return ok(snapshot)
|
||||||
|
|
||||||
|
|
||||||
@@ -747,6 +741,13 @@ def main() -> None:
|
|||||||
if reconciled:
|
if reconciled:
|
||||||
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
|
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
|
||||||
|
|
||||||
|
# Filesystem bootstrap after the import (which must see absent config
|
||||||
|
# files to adopt live system state on first start): create runtime
|
||||||
|
# directories and persist the one-shot nginx legacy-format migration.
|
||||||
|
from lib.bootstrap import bootstrap
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
|
|
||||||
# Reopen group access on the ACME home before the first acme.sh
|
# Reopen group access on the ACME home before the first acme.sh
|
||||||
# collection: a tree left owner-only by a prior run (e.g. a manual
|
# collection: a tree left owner-only by a prior run (e.g. a manual
|
||||||
# run as the WebUI user) would otherwise fail every daemon acme.sh
|
# run as the WebUI user) would otherwise fail every daemon acme.sh
|
||||||
|
|||||||
+29
-7
@@ -433,7 +433,9 @@ POST /api/firewall/config
|
|||||||
|
|
||||||
Replace the declarative config. Returns pending changes summary.
|
Replace the declarative config. Returns pending changes summary.
|
||||||
|
|
||||||
**Request Body:** Request body must contain `zones`.
|
**Request Body:** Request body must contain `zones`. An optional top-level `unmanaged` array (list of interface names) exempts those interfaces from the interface-coverage invariant.
|
||||||
|
|
||||||
|
**Errors:** Returns HTTP `400` when the body is malformed (missing/non-dict `zones`, non-list `unmanaged`) or when the config would leave a network-managed interface without zone coverage (the interface-coverage invariant — see `docs/config.md`).
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
@@ -452,6 +454,10 @@ POST /api/firewall/config/apply
|
|||||||
|
|
||||||
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
|
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
|
||||||
|
|
||||||
|
**Request Body:** Optional. Send `{"force": true}` to override the management-lockout and interface-coverage guards.
|
||||||
|
|
||||||
|
**Errors:** Returns HTTP `409` when the apply is refused by the management-lockout guard (https+ssh stripped from the default zone) or the interface-coverage invariant (a network-managed interface has no zone coverage and is not `unmanaged`). See `docs/config.md`.
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -478,6 +484,10 @@ PATCH /api/firewall/config
|
|||||||
|
|
||||||
Deep-merge the provided fields into the existing config. Returns pending changes summary.
|
Deep-merge the provided fields into the existing config. Returns pending changes summary.
|
||||||
|
|
||||||
|
**Request Body:** Partial config object; a provided `unmanaged` array replaces the existing one.
|
||||||
|
|
||||||
|
**Errors:** Returns HTTP `400` when the merged config is malformed or would leave a network-managed interface without zone coverage (interface-coverage invariant — see `docs/config.md`).
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -1983,8 +1993,8 @@ Apply pending changes for all subsystems in dependency order.
|
|||||||
```
|
```
|
||||||
|
|
||||||
`force` is forwarded to the firewall apply only — it overrides the
|
`force` is forwarded to the firewall apply only — it overrides the
|
||||||
management-lockout and interface-coverage guards. Other subsystems
|
management-lockout guard and the interface-coverage invariant. Other
|
||||||
ignore it.
|
subsystems ignore it.
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
@@ -1996,10 +2006,14 @@ ignore it.
|
|||||||
The endpoint returns `200` even when some subsystems failed — per-subsystem
|
The endpoint returns `200` even when some subsystems failed — per-subsystem
|
||||||
failures are reported in `errors`, so clients must check `errors` (not just
|
failures are reported in `errors`, so clients must check `errors` (not just
|
||||||
the HTTP status) before reporting success. Without `force`, the firewall
|
the HTTP status) before reporting success. Without `force`, the firewall
|
||||||
apply refuses if an interface would be left without zone coverage (the
|
apply is refused when a network-managed interface has no zone coverage in
|
||||||
coverage guard) or both https/ssh would be stripped from the default zone
|
the config and is not `unmanaged` (the interface-coverage invariant) or when
|
||||||
(lockout guard); the `ConflictError` surfaces in `errors` under
|
the config would strip both https/ssh from the default zone (lockout guard);
|
||||||
`"Firewall"` while the other subsystems proceed.
|
the `ConflictError` surfaces in `errors` under `"Firewall"` while the other
|
||||||
|
subsystems proceed. Pending state comes from
|
||||||
|
the last state poll (firewall 30s, dnsmasq 10s, nginx 60s, wireguard 10s,
|
||||||
|
networkd 10s), so an edit saved within the last poll interval may not be
|
||||||
|
picked up by this call.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2016,6 +2030,14 @@ Subsystems without a recorded baseline (config never applied) are
|
|||||||
reported as skipped and left untouched. No live-system commands run —
|
reported as skipped and left untouched. No live-system commands run —
|
||||||
only the declarative config files are written.
|
only the declarative config files are written.
|
||||||
|
|
||||||
|
Notes: pending state comes from the last state poll (firewall 30s,
|
||||||
|
dnsmasq 10s, nginx 60s, wireguard 10s, networkd 10s), so an edit saved
|
||||||
|
within the last poll interval is not yet flagged pending and is left in
|
||||||
|
place. For the firewall, pending is a config-vs-live diff: cancel
|
||||||
|
restores only the config file, so live firewalld drift made outside the
|
||||||
|
declarative config (manual `firewall-cmd`) is not reverted and the
|
||||||
|
firewall may still report pending after a cancel.
|
||||||
|
|
||||||
**Request Body:** none.
|
**Request Body:** none.
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|||||||
+17
-4
@@ -507,17 +507,25 @@ This file defines the declarative firewalld zone configuration. The application
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"unmanaged": ["eth9"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Top-Level Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `zones` | object | Yes | Zone name → zone configuration (below). |
|
||||||
|
| `unmanaged` | array | No | Network interfaces that are deliberately **not** covered by any zone. Exempts them from the [interface-coverage invariant](#interface-coverage-invariant). Default: `[]`. |
|
||||||
|
|
||||||
### Zone Fields
|
### Zone Fields
|
||||||
|
|
||||||
The `zones` object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via `firewall-cmd`.
|
The `zones` object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via `firewall-cmd`.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `interfaces` | array | No | Network interfaces assigned to this zone. Computed against live state to detect pending changes. Default: `[]`. |
|
| `interfaces` | array | No | Network interfaces assigned to this zone. The config is the source of truth: an omitted key counts as an empty list (apply unassigns the zone's live interfaces). Default: `[]`. |
|
||||||
| `services` | array | No | Firewalld services to allow in this zone (e.g., `ssh`, `https`, `dns`, `dhcp`). Default: `[]`. |
|
| `services` | array | No | Firewalld services to allow in this zone (e.g., `ssh`, `https`, `dns`, `dhcp`). 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). |
|
| `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`. |
|
| `masquerade` | boolean | No | Enable IP masquerading (NAT) for this zone. Default: `false`. |
|
||||||
@@ -532,13 +540,18 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
|
|||||||
|
|
||||||
### Applying Firewall Configuration
|
### 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`. 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.
|
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. The `interfaces` entry is reported for **every** config zone — the config is the source of truth for zone interfaces, so an omitted `interfaces` key counts as an empty list and pending changes are diffed accordingly.
|
||||||
|
|
||||||
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.
|
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.
|
**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`.
|
**Interface-coverage invariant.** Every network-subsystem-managed interface must be covered by a zone in the firewall config — otherwise all traffic (and DHCP) from that segment is 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). Because the config is the source of truth for zone interfaces (an omitted `interfaces` key counts as empty), coverage is computed from the config **alone** via `validate_coverage()` — there is no live-state fallback and no hands-off zones. Interfaces listed in the top-level `unmanaged` key are exempt. The invariant is enforced at two points:
|
||||||
|
|
||||||
|
- **Save time** — `POST /firewall/config` and `PATCH /firewall/config` reject a config that leaves a managed interface uncovered with `400 Bad Request`, before anything is written.
|
||||||
|
- **Apply time** — `POST /firewall/config/apply` re-checks the (possibly stale) saved config against the current network config and raises `409 Conflict` before any mutation. A conflict here means the network config changed after the firewall config was saved (e.g. a new interface no zone covers).
|
||||||
|
|
||||||
|
Send `"force": true` in the request body to override the apply-time check (the UI offers this via the Apply dialog). Live drift — an interface that is covered by the config but not in any **live** zone — is advisory only: it is surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`), the Zones-page banner, and an advisory in `GET /api/status/pending`, and is never blocked by the invariant.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -27,7 +27,11 @@ return annotation references them.
|
|||||||
internal and stripped from every state/API config payload. Canceling
|
internal and stripped from every state/API config payload. Canceling
|
||||||
pending changes (`POST /api/status/cancel-all`) restores a pending
|
pending changes (`POST /api/status/cancel-all`) restores a pending
|
||||||
config file from its snapshot; a subsystem with no recorded baseline
|
config file from its snapshot; a subsystem with no recorded baseline
|
||||||
(never applied) is reported as skipped, not reset.
|
(never applied) is reported as skipped, not reset. Apply-all and
|
||||||
|
cancel-all both decide from this last-poll state (an edit saved within
|
||||||
|
the last poll interval may not yet be flagged), and cancel reverts only
|
||||||
|
the declarative config file — live drift (e.g. manual `firewall-cmd`)
|
||||||
|
survives a cancel.
|
||||||
|
|
||||||
## State shape summary
|
## State shape summary
|
||||||
|
|
||||||
@@ -63,9 +67,12 @@ Top-level `FirewallState`:
|
|||||||
// (lib/firewall.py get_service_descriptions,
|
// (lib/firewall.py get_service_descriptions,
|
||||||
// cached per process)
|
// cached per process)
|
||||||
uncovered_interfaces: [str], // network-config interfaces (excluding
|
uncovered_interfaces: [str], // network-config interfaces (excluding
|
||||||
// lo/wg*) not in any live zone —
|
// lo/wg*) not in any LIVE zone — a
|
||||||
// advisory coverage warning; empty =
|
// live-drift advisory (config may still
|
||||||
// fully covered; NOT counted in pending
|
// cover them); distinct from the
|
||||||
|
// config-based interface-coverage
|
||||||
|
// invariant (docs/config.md); NOT
|
||||||
|
// counted in pending
|
||||||
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
|
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
|
||||||
// may carry "sources", "ports",
|
// may carry "sources", "ports",
|
||||||
// "protocols", "forward-ports", "ics",
|
// "protocols", "forward-ports", "ics",
|
||||||
|
|||||||
+5
-2
@@ -95,11 +95,14 @@ def _run_acme(args: list[str]) -> str:
|
|||||||
acme_home_env,
|
acme_home_env,
|
||||||
"--config-home",
|
"--config-home",
|
||||||
acme_home_env,
|
acme_home_env,
|
||||||
|
*args,
|
||||||
# Append the full transcript to $ACME_HOME/acme.sh.log so manual
|
# Append the full transcript to $ACME_HOME/acme.sh.log so manual
|
||||||
# runs (whose stdout is captured below) leave a persistent record
|
# runs (whose stdout is captured below) leave a persistent record
|
||||||
# of the raw CA exchange.
|
# of the raw CA exchange. Last on purpose: acme.sh treats the next
|
||||||
|
# token after --log as its optional file argument, so a trailing
|
||||||
|
# --log defaults the log to $LE_CONFIG_HOME/acme.sh.log and can
|
||||||
|
# never swallow a real argument.
|
||||||
"--log",
|
"--log",
|
||||||
*args,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Daemon-startup filesystem bootstrap.
|
||||||
|
|
||||||
|
Runs once at daemon startup, after the system-config import and before the
|
||||||
|
first state collection. Creates the runtime directories subsystems
|
||||||
|
read/write and persists the one-shot nginx legacy-format migration.
|
||||||
|
|
||||||
|
Config *files* are deliberately NOT created here: ``get_config`` reads are
|
||||||
|
pure and return in-memory defaults, and the system-config import must see
|
||||||
|
absent files in order to adopt live system state on first start. Files are
|
||||||
|
materialized on the first ``save_config`` (or by the import itself).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from lib import dnsmasq, firewall, network, nginx, wireguard
|
||||||
|
from lib.common import ensure_dirs
|
||||||
|
|
||||||
|
__all__ = ["bootstrap"]
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap() -> None:
|
||||||
|
"""Create runtime directories and persist the one-shot nginx migration.
|
||||||
|
|
||||||
|
Idempotent — existing directories are left untouched and the nginx
|
||||||
|
migration only rewrites the on-disk file when it actually changes.
|
||||||
|
"""
|
||||||
|
ensure_dirs(
|
||||||
|
dnsmasq.CONFIG_DIR,
|
||||||
|
dnsmasq.DATA_DIR,
|
||||||
|
dnsmasq.FRAGMENTS_DIR,
|
||||||
|
firewall.CONFIG_DIR,
|
||||||
|
firewall.DATA_DIR,
|
||||||
|
network.CONFIG_DIR,
|
||||||
|
network.DATA_DIR,
|
||||||
|
nginx.CONFIG_DIR,
|
||||||
|
nginx.SITES_DIR,
|
||||||
|
wireguard.CONFIG_PATH.parent,
|
||||||
|
)
|
||||||
|
# One-shot legacy-format migration for the nginx config (see
|
||||||
|
# ``lib.nginx.get_config``). Runs here, at startup, so read paths stay
|
||||||
|
# side-effect free.
|
||||||
|
nginx.migrate_config_file()
|
||||||
@@ -120,6 +120,24 @@ def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> Non
|
|||||||
out.append({"path": path, "action": "changed", "old": old, "new": new})
|
out.append({"path": path, "action": "changed", "old": old, "new": new})
|
||||||
|
|
||||||
|
|
||||||
|
def compute_pending(cfg: dict[str, Any]) -> tuple[bool, list[dict[str, Any]]]:
|
||||||
|
"""Return ``(pending_changes, pending_diff)`` from apply bookkeeping keys.
|
||||||
|
|
||||||
|
``pending_changes`` is ``True`` when the config was never applied or its
|
||||||
|
content no longer matches the recorded ``_last_applied_hash``. When
|
||||||
|
pending and a ``_last_applied_config`` snapshot is recorded, the diff is a
|
||||||
|
field-level comparison of the snapshot against the current (meta-stripped)
|
||||||
|
config; otherwise it is empty.
|
||||||
|
"""
|
||||||
|
pending = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(cfg)
|
||||||
|
diff: list[dict[str, Any]] = []
|
||||||
|
if pending:
|
||||||
|
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||||
|
if isinstance(snap, dict):
|
||||||
|
diff = deep_diff(snap, strip_apply_meta(cfg))
|
||||||
|
return pending, diff
|
||||||
|
|
||||||
|
|
||||||
def validate_interface_name(name: str) -> str:
|
def validate_interface_name(name: str) -> str:
|
||||||
"""Validate a Linux network interface name.
|
"""Validate a Linux network interface name.
|
||||||
|
|
||||||
@@ -301,6 +319,7 @@ __all__ = [
|
|||||||
"_APPLY_HASH_KEY",
|
"_APPLY_HASH_KEY",
|
||||||
"_LAST_APPLIED_CONFIG_KEY",
|
"_LAST_APPLIED_CONFIG_KEY",
|
||||||
"_hash_password",
|
"_hash_password",
|
||||||
|
"compute_pending",
|
||||||
"config_hash",
|
"config_hash",
|
||||||
"deep_diff",
|
"deep_diff",
|
||||||
"deep_merge",
|
"deep_merge",
|
||||||
|
|||||||
+11
-2
@@ -37,8 +37,12 @@ DEFAULT_CFG: dict[str, Any] = {
|
|||||||
|
|
||||||
|
|
||||||
def get_config() -> dict[str, Any]:
|
def get_config() -> dict[str, Any]:
|
||||||
"""Load current dnsmasq config from JSON state file."""
|
"""Load current dnsmasq config from JSON state file.
|
||||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
|
||||||
|
Pure read — never writes or creates directories. Returns the in-memory
|
||||||
|
default when the file is missing; directories and the file are
|
||||||
|
materialized on the first ``save_config``.
|
||||||
|
"""
|
||||||
raw = load_json(CONFIG_PATH)
|
raw = load_json(CONFIG_PATH)
|
||||||
if not raw:
|
if not raw:
|
||||||
return deepcopy(DEFAULT_CFG)
|
return deepcopy(DEFAULT_CFG)
|
||||||
@@ -73,6 +77,11 @@ def set_domain(domain: str | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CONFIG_DIR",
|
||||||
|
"CONFIG_PATH",
|
||||||
|
"DATA_DIR",
|
||||||
|
"DEFAULT_CFG",
|
||||||
|
"FRAGMENTS_DIR",
|
||||||
"get_config",
|
"get_config",
|
||||||
"save_config",
|
"save_config",
|
||||||
"set_domain",
|
"set_domain",
|
||||||
|
|||||||
+61
-9
@@ -7,6 +7,7 @@ All privileged commands are handled by daemon/handlers/firewall.py.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -327,9 +328,16 @@ def _ensure_config_file() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_config() -> dict[str, Any]:
|
def get_config() -> dict[str, Any]:
|
||||||
"""Return the declarative config from ``config/firewall/config.json``."""
|
"""Return the declarative config from ``config/firewall/config.json``.
|
||||||
_ensure_config_file()
|
|
||||||
return load_json(CONFIG_FILE)
|
Pure read — never writes. Returns the in-memory default when the file
|
||||||
|
is missing; the file is materialized on the first ``save_config`` (or
|
||||||
|
by the system-config import on first start).
|
||||||
|
"""
|
||||||
|
raw = load_json(CONFIG_FILE)
|
||||||
|
if not raw:
|
||||||
|
return deepcopy(DEFAULT_CONFIG)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict[str, Any]) -> None:
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
@@ -370,11 +378,10 @@ def _compute_pending_changes(
|
|||||||
Pure function — no subprocess calls. Caller is responsible for providing
|
Pure function — no subprocess calls. Caller is responsible for providing
|
||||||
live state (typically from the daemon).
|
live state (typically from the daemon).
|
||||||
|
|
||||||
The interfaces diff is only reported for zones whose config explicitly
|
The config is the source of truth for zone interfaces: an absent
|
||||||
carries an ``interfaces`` key; zones with the key absent are hands-off
|
``interfaces`` key counts as an empty list, so every config zone is
|
||||||
(apply keeps their live interfaces), so diffing them would advertise
|
diffed on interfaces. Likewise the target diff is only reported when the
|
||||||
changes that never happen. Likewise the target diff is only reported when
|
config carries an explicit target that normalizes to something other
|
||||||
the config carries an explicit target that normalizes to something other
|
|
||||||
than ``default`` — an absent key or a ``default``-normalizing value is
|
than ``default`` — an absent key or a ``default``-normalizing value is
|
||||||
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
|
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
|
||||||
forward ports are reported for all config zones.
|
forward ports are reported for all config zones.
|
||||||
@@ -387,7 +394,8 @@ def _compute_pending_changes(
|
|||||||
for zone_name, zone_cfg in cfg_zones.items():
|
for zone_name, zone_cfg in cfg_zones.items():
|
||||||
live_zone = live_zones.get(zone_name, {})
|
live_zone = live_zones.get(zone_name, {})
|
||||||
|
|
||||||
if "interfaces" in zone_cfg:
|
# The config is the source of truth for zone interfaces: an absent
|
||||||
|
# key counts as an empty list, so every config zone is diffed.
|
||||||
cfg_ifaces = set(zone_cfg.get("interfaces", []))
|
cfg_ifaces = set(zone_cfg.get("interfaces", []))
|
||||||
live_ifaces = set(live_zone.get("interfaces", []))
|
live_ifaces = set(live_zone.get("interfaces", []))
|
||||||
if cfg_ifaces != live_ifaces:
|
if cfg_ifaces != live_ifaces:
|
||||||
@@ -488,6 +496,49 @@ def _compute_pending_changes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_coverage(fw_cfg: dict[str, Any], net_cfg: dict[str, Any]) -> list[str]:
|
||||||
|
"""Return network-managed interfaces with no firewall zone coverage.
|
||||||
|
|
||||||
|
Pure — compares the declarative firewall config against the network
|
||||||
|
config; no live state. A managed interface is covered when it appears in
|
||||||
|
some zone's ``interfaces`` list (an absent key counts as empty), or is
|
||||||
|
explicitly declared in the top-level ``unmanaged`` list. ``lo`` and
|
||||||
|
``wg*`` interfaces are never guarded (VPN zones are managed by the
|
||||||
|
WireGuard sync; loopback is normally zoneless).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fw_cfg: Firewall declarative config (``zones`` plus optional
|
||||||
|
top-level ``unmanaged`` list).
|
||||||
|
net_cfg: Network config (``interfaces`` mapping).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of uncovered interface names; empty when the config is
|
||||||
|
valid.
|
||||||
|
"""
|
||||||
|
managed = [
|
||||||
|
name
|
||||||
|
for name in net_cfg.get("interfaces", {})
|
||||||
|
if name != "lo" and not name.startswith("wg")
|
||||||
|
]
|
||||||
|
if not managed:
|
||||||
|
return []
|
||||||
|
covered: set[str] = set()
|
||||||
|
for zone_cfg in fw_cfg.get("zones", {}).values():
|
||||||
|
if isinstance(zone_cfg, dict):
|
||||||
|
covered.update(
|
||||||
|
i for i in zone_cfg.get("interfaces", []) if isinstance(i, str)
|
||||||
|
)
|
||||||
|
unmanaged_raw = fw_cfg.get("unmanaged", [])
|
||||||
|
unmanaged = (
|
||||||
|
{i for i in unmanaged_raw if isinstance(i, str)}
|
||||||
|
if isinstance(unmanaged_raw, list)
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
name for name in managed if name not in covered and name not in unmanaged
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def config_pending(state: dict[str, Any]) -> dict[str, Any]:
|
def config_pending(state: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Compare declarative config against firewalld live state, return diff.
|
"""Compare declarative config against firewalld live state, return diff.
|
||||||
|
|
||||||
@@ -554,4 +605,5 @@ __all__ = [
|
|||||||
"load_backup",
|
"load_backup",
|
||||||
"save_backup",
|
"save_backup",
|
||||||
"save_config",
|
"save_config",
|
||||||
|
"validate_coverage",
|
||||||
]
|
]
|
||||||
|
|||||||
+8
-4
@@ -8,6 +8,7 @@ import contextlib
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -78,13 +79,16 @@ __all__ = [
|
|||||||
def get_config() -> dict[str, Any]:
|
def get_config() -> dict[str, Any]:
|
||||||
"""Read network config from config/network/config.json.
|
"""Read network config from config/network/config.json.
|
||||||
|
|
||||||
|
Pure read — never writes. Returns the in-memory default when the file
|
||||||
|
is missing; the file is materialized on the first ``save_config``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with ``interfaces`` mapping interface names to config entries.
|
Dict with ``interfaces`` mapping interface names to config entries.
|
||||||
"""
|
"""
|
||||||
if not CONFIG_FILE.exists():
|
raw = load_json(CONFIG_FILE)
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
if not raw:
|
||||||
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
|
return deepcopy(DEFAULT_CONFIG)
|
||||||
return load_json(CONFIG_FILE)
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict[str, Any]) -> None:
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
|
|||||||
+24
-9
@@ -169,31 +169,45 @@ def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_config() -> dict[str, Any]:
|
def get_config() -> dict[str, Any]:
|
||||||
"""Load the current nginx config, initializing with defaults if needed.
|
"""Load the current nginx config (pure read, in-memory migration).
|
||||||
|
|
||||||
Ensures config and sites directories exist, applies migrations for
|
Never writes or creates directories. Returns the in-memory default when
|
||||||
legacy formats, then returns the config dict.
|
the file is missing and applies legacy-format migration in memory, so
|
||||||
|
read paths (state collectors, apply-time checks) stay side-effect free.
|
||||||
|
The one-shot on-disk migration runs at daemon startup via
|
||||||
|
``migrate_config_file``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
|
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
|
||||||
"""
|
"""
|
||||||
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
|
||||||
raw = load_json(CONFIG_FILE)
|
raw = load_json(CONFIG_FILE)
|
||||||
if not raw:
|
if not raw:
|
||||||
raw = deepcopy(DEFAULT_CONFIG)
|
raw = deepcopy(DEFAULT_CONFIG)
|
||||||
save_config(raw)
|
|
||||||
return raw
|
|
||||||
if "ssl" not in raw:
|
if "ssl" not in raw:
|
||||||
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||||
if "backends" not in raw:
|
if "backends" not in raw:
|
||||||
raw["backends"] = {}
|
raw["backends"] = {}
|
||||||
|
return _migrate_config(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_config_file() -> bool:
|
||||||
|
"""Persist the one-shot legacy-format migration, if the file needs it.
|
||||||
|
|
||||||
|
Runs at daemon startup so ``get_config`` reads stay pure. Rewrites the
|
||||||
|
on-disk file only when migration actually changes it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True when the on-disk file was rewritten, False otherwise.
|
||||||
|
"""
|
||||||
|
raw = load_json(CONFIG_FILE)
|
||||||
|
if not raw:
|
||||||
|
return False
|
||||||
pre = deepcopy(raw)
|
pre = deepcopy(raw)
|
||||||
raw = _migrate_config(raw)
|
raw = _migrate_config(raw)
|
||||||
# Read-only unless normalization/migration actually changed the config;
|
|
||||||
# re-saving on every read rewrites the file (owner/mtime churn).
|
|
||||||
if raw != pre:
|
if raw != pre:
|
||||||
save_config(raw)
|
save_config(raw)
|
||||||
return raw
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict[str, Any]) -> None:
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
@@ -616,6 +630,7 @@ __all__ = [
|
|||||||
"get_config",
|
"get_config",
|
||||||
"get_domains",
|
"get_domains",
|
||||||
"get_management_domains",
|
"get_management_domains",
|
||||||
|
"migrate_config_file",
|
||||||
"remove_domain",
|
"remove_domain",
|
||||||
"save_config",
|
"save_config",
|
||||||
"test_config",
|
"test_config",
|
||||||
|
|||||||
+12
-997
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -370,7 +370,7 @@ def status() -> dict[str, Any]:
|
|||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
result["classes"][class_key] = {"up": False, "peers": []}
|
result["classes"][class_key] = {"up": False, "peers": []}
|
||||||
continue
|
continue
|
||||||
class_status = _parse_wg_show_output(res.stdout.strip())
|
class_status = parse_wg_show_output(res.stdout.strip())
|
||||||
result["classes"][class_key] = class_status
|
result["classes"][class_key] = class_status
|
||||||
if class_status["up"]:
|
if class_status["up"]:
|
||||||
result["up"] = True
|
result["up"] = True
|
||||||
@@ -382,7 +382,7 @@ def status() -> dict[str, Any]:
|
|||||||
ifname = cfg["interface"].get("name", "wg0")
|
ifname = cfg["interface"].get("name", "wg0")
|
||||||
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
|
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
|
||||||
if res.returncode == 0:
|
if res.returncode == 0:
|
||||||
parsed = _parse_wg_show_output(res.stdout.strip())
|
parsed = parse_wg_show_output(res.stdout.strip())
|
||||||
result["up"] = parsed["up"]
|
result["up"] = parsed["up"]
|
||||||
result["interface"] = parsed.get("interface", {})
|
result["interface"] = parsed.get("interface", {})
|
||||||
result["peers"] = parsed.get("peers", [])
|
result["peers"] = parsed.get("peers", [])
|
||||||
@@ -392,8 +392,12 @@ def status() -> dict[str, Any]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _parse_wg_show_output(raw: str) -> dict[str, Any]:
|
def parse_wg_show_output(raw: str) -> dict[str, Any]:
|
||||||
"""Parse ``wg show`` output into structured dict."""
|
"""Parse ``wg show`` output into structured dict.
|
||||||
|
|
||||||
|
Returns ``{"up", "interface", "peers"}`` where *interface* carries
|
||||||
|
``public_key``, ``listen_port`` and (when present) ``fwmark``.
|
||||||
|
"""
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"up": False,
|
"up": False,
|
||||||
"interface": {},
|
"interface": {},
|
||||||
@@ -742,6 +746,7 @@ __all__ = [
|
|||||||
"get_peer_status",
|
"get_peer_status",
|
||||||
"get_peers",
|
"get_peers",
|
||||||
"initialize",
|
"initialize",
|
||||||
|
"parse_wg_show_output",
|
||||||
"remove_peer",
|
"remove_peer",
|
||||||
"save_config",
|
"save_config",
|
||||||
"set_listen_port",
|
"set_listen_port",
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ class TestRunAcme:
|
|||||||
assert cmd[0] == "/usr/local/bin/acme.sh"
|
assert cmd[0] == "/usr/local/bin/acme.sh"
|
||||||
assert "sudo" not in cmd
|
assert "sudo" not in cmd
|
||||||
|
|
||||||
|
@patch("lib.acme._find_acme")
|
||||||
|
@patch("lib.acme.subprocess.run")
|
||||||
|
def test_log_flag_is_last(self, mock_run, mock_find):
|
||||||
|
# --log must trail the subcommand args: acme.sh would otherwise
|
||||||
|
# consume the first subcommand arg as its (optional) file argument.
|
||||||
|
mock_find.return_value = "/usr/local/bin/acme.sh"
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
acme._run_acme(["--issue", "-d", "example.com"])
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert cmd.count("--log") == 1
|
||||||
|
assert cmd[-1] == "--log"
|
||||||
|
assert cmd.index("--issue") < cmd.index("--log")
|
||||||
|
assert "example.com" in cmd
|
||||||
|
|
||||||
|
|
||||||
class TestParseListOutput:
|
class TestParseListOutput:
|
||||||
def test_parses_single_entry(self):
|
def test_parses_single_entry(self):
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for the daemon-startup filesystem bootstrap (lib.bootstrap)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lib import bootstrap, dnsmasq, firewall, network, nginx, wireguard
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def sandbox(tmp_path, monkeypatch):
|
||||||
|
"""Point every bootstrap-referenced path into a throwaway tree."""
|
||||||
|
cfg = tmp_path / "config"
|
||||||
|
data = tmp_path / "data"
|
||||||
|
monkeypatch.setattr(dnsmasq, "CONFIG_DIR", cfg / "dnsmasq")
|
||||||
|
monkeypatch.setattr(dnsmasq, "DATA_DIR", data / "dnsmasq")
|
||||||
|
monkeypatch.setattr(dnsmasq, "FRAGMENTS_DIR", data / "dnsmasq" / "fragments")
|
||||||
|
monkeypatch.setattr(firewall, "CONFIG_DIR", cfg / "firewall")
|
||||||
|
monkeypatch.setattr(firewall, "DATA_DIR", data / "firewall")
|
||||||
|
monkeypatch.setattr(network, "CONFIG_DIR", cfg / "network")
|
||||||
|
monkeypatch.setattr(network, "DATA_DIR", data / "networkd")
|
||||||
|
monkeypatch.setattr(nginx, "CONFIG_DIR", cfg / "nginx")
|
||||||
|
monkeypatch.setattr(nginx, "DATA_DIR", data / "nginx")
|
||||||
|
monkeypatch.setattr(nginx, "SITES_DIR", data / "nginx" / "sites-enabled")
|
||||||
|
monkeypatch.setattr(nginx, "CONFIG_FILE", cfg / "nginx" / "config.json")
|
||||||
|
monkeypatch.setattr(wireguard, "CONFIG_PATH", cfg / "wireguard" / "config.json")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_creates_runtime_dirs(sandbox):
|
||||||
|
bootstrap.bootstrap()
|
||||||
|
assert dnsmasq.FRAGMENTS_DIR.is_dir()
|
||||||
|
assert firewall.DATA_DIR.is_dir()
|
||||||
|
assert network.DATA_DIR.is_dir()
|
||||||
|
assert nginx.SITES_DIR.is_dir()
|
||||||
|
assert wireguard.CONFIG_PATH.parent.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_create_config_files(sandbox):
|
||||||
|
# Config files are left for system-import (first start) or the first
|
||||||
|
# save_config — bootstrap must not pre-empt either.
|
||||||
|
bootstrap.bootstrap()
|
||||||
|
assert not nginx.CONFIG_FILE.exists()
|
||||||
|
assert not (dnsmasq.CONFIG_DIR / "config.json").exists()
|
||||||
|
assert not (network.CONFIG_DIR / "config.json").exists()
|
||||||
|
assert not (firewall.CONFIG_DIR / "config.json").exists()
|
||||||
|
assert not wireguard.CONFIG_PATH.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_persists_nginx_migration(sandbox):
|
||||||
|
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
||||||
|
bootstrap.bootstrap()
|
||||||
|
on_disk = nginx.get_config()
|
||||||
|
assert on_disk["backends"]["webui"]["_migrated"] is True
|
||||||
|
raw = nginx.CONFIG_FILE.read_text()
|
||||||
|
assert '"_migrated": true' in raw or '"_migrated":True' in raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent(sandbox):
|
||||||
|
nginx.save_config({"domains": {}})
|
||||||
|
bootstrap.bootstrap()
|
||||||
|
mtime = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||||
|
bootstrap.bootstrap()
|
||||||
|
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from lib.common import (
|
from lib.common import (
|
||||||
_APPLY_HASH_KEY,
|
_APPLY_HASH_KEY,
|
||||||
_LAST_APPLIED_CONFIG_KEY,
|
_LAST_APPLIED_CONFIG_KEY,
|
||||||
|
compute_pending,
|
||||||
config_hash,
|
config_hash,
|
||||||
deep_diff,
|
deep_diff,
|
||||||
load_json,
|
load_json,
|
||||||
@@ -78,6 +79,56 @@ class TestDeepDiff:
|
|||||||
assert not any(p.startswith("z.ranges[0].n") for p in paths)
|
assert not any(p.startswith("z.ranges[0].n") for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputePending:
|
||||||
|
def test_hash_match_no_pending(self):
|
||||||
|
cfg = {"a": 1}
|
||||||
|
stamp_applied(cfg)
|
||||||
|
pending, diff = compute_pending(cfg)
|
||||||
|
assert pending is False
|
||||||
|
assert diff == []
|
||||||
|
|
||||||
|
def test_never_applied_pending_no_snapshot(self):
|
||||||
|
pending, diff = compute_pending({"a": 1})
|
||||||
|
assert pending is True
|
||||||
|
assert diff == []
|
||||||
|
|
||||||
|
def test_never_applied_pending_with_foreign_snapshot(self):
|
||||||
|
# A recorded snapshot that does not match the current hash is still
|
||||||
|
# used for the diff.
|
||||||
|
cfg = {"a": 2, _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
|
||||||
|
pending, diff = compute_pending(cfg)
|
||||||
|
assert pending is True
|
||||||
|
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
|
||||||
|
|
||||||
|
def test_hash_mismatch_with_snapshot_diffs(self):
|
||||||
|
applied = {"zones": {"lan": {"services": ["http"]}}}
|
||||||
|
stamped = dict(applied)
|
||||||
|
stamp_applied(stamped)
|
||||||
|
drifted = {"zones": {"lan": {"services": ["http", "ssh"]}}}
|
||||||
|
drifted[_LAST_APPLIED_CONFIG_KEY] = applied
|
||||||
|
drifted[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
|
||||||
|
pending, diff = compute_pending(drifted)
|
||||||
|
assert pending is True
|
||||||
|
paths = {d["path"] for d in diff}
|
||||||
|
assert "zones.lan.services" in paths
|
||||||
|
|
||||||
|
def test_hash_mismatch_snapshot_not_dict(self):
|
||||||
|
cfg = {"a": 1, _LAST_APPLIED_CONFIG_KEY: "not-a-dict"}
|
||||||
|
pending, diff = compute_pending(cfg)
|
||||||
|
assert pending is True
|
||||||
|
assert diff == []
|
||||||
|
|
||||||
|
def test_meta_keys_excluded_from_diff(self):
|
||||||
|
cfg = {"a": 1}
|
||||||
|
stamp_applied(cfg)
|
||||||
|
cfg["a"] = 2 # drift
|
||||||
|
pending, diff = compute_pending(cfg)
|
||||||
|
assert pending is True
|
||||||
|
assert not any(
|
||||||
|
p.startswith(("_last_applied",)) for d in diff for p in [d["path"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDashboardFallback:
|
class TestDashboardFallback:
|
||||||
def test_hash_subsystem_unchanged_generic(self):
|
def test_hash_subsystem_unchanged_generic(self):
|
||||||
# Guards that a pending status without a snapshot still yields a
|
# Guards that a pending status without a snapshot still yields a
|
||||||
|
|||||||
+227
-91
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from daemon.handlers import common as daemoncommon
|
||||||
from daemon.handlers import firewall as daemonfirewall
|
from daemon.handlers import firewall as daemonfirewall
|
||||||
from daemon.server import ConflictError, NotFoundError
|
from daemon.server import ConflictError, NotFoundError
|
||||||
from lib import firewall
|
from lib import firewall
|
||||||
@@ -377,25 +378,26 @@ _PENDING_LIVE_PUBLIC = {
|
|||||||
|
|
||||||
|
|
||||||
class TestComputePendingChangesAbsentInterfaces:
|
class TestComputePendingChangesAbsentInterfaces:
|
||||||
"""Zones whose config lacks the 'interfaces' key are hands-off on apply,
|
"""The config is the source of truth for zone interfaces: an absent
|
||||||
so their interfaces diff must not be reported; other field drift is."""
|
'interfaces' key counts as an empty list, so every config zone is
|
||||||
|
diffed on interfaces (no hands-off zones)."""
|
||||||
|
|
||||||
def test_services_drift_reported_without_interfaces_key(self):
|
def test_services_and_interfaces_drift_reported_without_interfaces_key(self):
|
||||||
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
|
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
|
||||||
result = firewall._compute_pending_changes(
|
result = firewall._compute_pending_changes(
|
||||||
cfg, {"public": _PENDING_LIVE_PUBLIC}
|
cfg, {"public": _PENDING_LIVE_PUBLIC}
|
||||||
)
|
)
|
||||||
types = {c["type"] for c in result["pending"]}
|
types = {c["type"] for c in result["pending"]}
|
||||||
assert "services" in types
|
assert "services" in types
|
||||||
assert "interfaces" not in types
|
# Absent key counts as an empty list: live eth0 is a pending removal.
|
||||||
|
assert "interfaces" in types
|
||||||
|
|
||||||
def test_no_spurious_interfaces_entry_for_absent_key_zone(self):
|
def test_absent_key_zone_in_sync_live_reports_nothing(self):
|
||||||
# Config in sync on everything except a missing interfaces key: the
|
# Config lacks the interfaces key and the live zone has no
|
||||||
# zone's live interfaces are intentionally left alone by apply.
|
# interfaces either — absent key equals the empty live set.
|
||||||
|
live = {**_PENDING_LIVE_PUBLIC, "interfaces": []}
|
||||||
cfg = {"zones": {"public": {"services": ["http"]}}}
|
cfg = {"zones": {"public": {"services": ["http"]}}}
|
||||||
result = firewall._compute_pending_changes(
|
result = firewall._compute_pending_changes(cfg, {"public": live})
|
||||||
cfg, {"public": _PENDING_LIVE_PUBLIC}
|
|
||||||
)
|
|
||||||
assert result["pending"] == []
|
assert result["pending"] == []
|
||||||
assert result["needs_apply"] is False
|
assert result["needs_apply"] is False
|
||||||
|
|
||||||
@@ -457,6 +459,51 @@ class TestTargetDriftSemantics:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateCoverage:
|
||||||
|
def test_all_covered(self):
|
||||||
|
fw = {"zones": {"public": {"interfaces": ["eth0"]}}}
|
||||||
|
net = {"interfaces": {"eth0": {}}}
|
||||||
|
assert firewall.validate_coverage(fw, net) == []
|
||||||
|
|
||||||
|
def test_uncovered_reported_sorted(self):
|
||||||
|
fw = {"zones": {"public": {"interfaces": ["eth1"]}}}
|
||||||
|
net = {"interfaces": {"eth5": {}, "eth0": {}}}
|
||||||
|
assert firewall.validate_coverage(fw, net) == ["eth0", "eth5"]
|
||||||
|
|
||||||
|
def test_unmanaged_exempts(self):
|
||||||
|
fw = {
|
||||||
|
"zones": {"public": {"interfaces": ["eth1"]}},
|
||||||
|
"unmanaged": ["eth0"],
|
||||||
|
}
|
||||||
|
net = {"interfaces": {"eth0": {}, "eth1": {}}}
|
||||||
|
assert firewall.validate_coverage(fw, net) == []
|
||||||
|
|
||||||
|
def test_lo_and_wg_exempt(self):
|
||||||
|
fw = {"zones": {}}
|
||||||
|
net = {"interfaces": {"lo": {}, "wg0": {}, "wg-full": {}}}
|
||||||
|
assert firewall.validate_coverage(fw, net) == []
|
||||||
|
|
||||||
|
def test_absent_key_counts_as_empty(self):
|
||||||
|
# A zone without an 'interfaces' key covers nothing.
|
||||||
|
fw = {"zones": {"public": {"services": ["http"]}}}
|
||||||
|
net = {"interfaces": {"eth0": {}}}
|
||||||
|
assert firewall.validate_coverage(fw, net) == ["eth0"]
|
||||||
|
|
||||||
|
def test_empty_network_config(self):
|
||||||
|
assert firewall.validate_coverage({"zones": {}}, {"interfaces": {}}) == []
|
||||||
|
assert firewall.validate_coverage({"zones": {}}, {}) == []
|
||||||
|
|
||||||
|
def test_non_dict_zone_and_non_list_unmanaged_ignored(self):
|
||||||
|
fw = {"zones": {"public": "oops"}, "unmanaged": "eth0"}
|
||||||
|
net = {"interfaces": {"eth0": {}}}
|
||||||
|
assert firewall.validate_coverage(fw, net) == ["eth0"]
|
||||||
|
|
||||||
|
def test_non_string_entries_ignored(self):
|
||||||
|
fw = {"zones": {"public": {"interfaces": [None, 7]}}, "unmanaged": [None]}
|
||||||
|
net = {"interfaces": {"eth0": {}}}
|
||||||
|
assert firewall.validate_coverage(fw, net) == ["eth0"]
|
||||||
|
|
||||||
|
|
||||||
class TestGetZoneInfo:
|
class TestGetZoneInfo:
|
||||||
def test_parses_zone_info(self):
|
def test_parses_zone_info(self):
|
||||||
result = firewall._parse_zone_output(
|
result = firewall._parse_zone_output(
|
||||||
@@ -656,7 +703,7 @@ class TestDaemonConfigApply:
|
|||||||
"daemon.handlers.firewall._get_state",
|
"daemon.handlers.firewall._get_state",
|
||||||
return_value={"zones": {"public": {}}},
|
return_value={"zones": {"public": {}}},
|
||||||
),
|
),
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
patch(
|
patch(
|
||||||
"daemon.handlers.firewall._get_config",
|
"daemon.handlers.firewall._get_config",
|
||||||
return_value={"zones": {"public": {}}},
|
return_value={"zones": {"public": {}}},
|
||||||
@@ -702,8 +749,8 @@ class TestDaemonMgmtLockoutGuard:
|
|||||||
patch.object(daemonfirewall, "_reload"),
|
patch.object(daemonfirewall, "_reload"),
|
||||||
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
):
|
):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
result = daemonfirewall.set_zone_services(
|
result = daemonfirewall.set_zone_services(
|
||||||
@@ -723,8 +770,8 @@ class TestDaemonMgmtLockoutGuard:
|
|||||||
patch.object(daemonfirewall, "_reload"),
|
patch.object(daemonfirewall, "_reload"),
|
||||||
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||||
patch.object(daemonfirewall, "_save_config"),
|
patch.object(daemonfirewall, "_save_config"),
|
||||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
):
|
):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
result = daemonfirewall.set_zone_services(
|
result = daemonfirewall.set_zone_services(
|
||||||
@@ -791,7 +838,7 @@ class TestDaemonMgmtLockoutGuard:
|
|||||||
"daemon.handlers.firewall._get_state",
|
"daemon.handlers.firewall._get_state",
|
||||||
return_value={"zones": {"public": {}}},
|
return_value={"zones": {"public": {}}},
|
||||||
),
|
),
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
patch(
|
patch(
|
||||||
"daemon.handlers.firewall._get_config",
|
"daemon.handlers.firewall._get_config",
|
||||||
return_value={"zones": {"public": {}}},
|
return_value={"zones": {"public": {}}},
|
||||||
@@ -803,8 +850,10 @@ class TestDaemonMgmtLockoutGuard:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Interface-coverage guard: apply must not leave a network-managed interface
|
# Coverage invariant: the config must cover every network-managed
|
||||||
# in no zone (clients lose connectivity/DHCP) unless forced.
|
# interface (or declare it unmanaged). Pure config check — the config is
|
||||||
|
# the source of truth for zone interfaces (absent key = empty), so there
|
||||||
|
# is no live-state comparison and no hands-off zones.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -849,7 +898,7 @@ def _apply_with(
|
|||||||
) as mock_run,
|
) as mock_run,
|
||||||
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||||
patch("daemon.handlers.firewall._save_backup", return_value=backup),
|
patch("daemon.handlers.firewall._save_backup", return_value=backup),
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
patch(
|
patch(
|
||||||
"daemon.handlers.firewall._get_config",
|
"daemon.handlers.firewall._get_config",
|
||||||
return_value=deepcopy(cfg),
|
return_value=deepcopy(cfg),
|
||||||
@@ -860,21 +909,88 @@ def _apply_with(
|
|||||||
return result, mock_run
|
return result, mock_run
|
||||||
|
|
||||||
|
|
||||||
class TestDaemonInterfaceCoverageGuard:
|
class TestDaemonCoverageInvariant:
|
||||||
def test_absent_key_zone_keeps_live_interfaces_on_apply(self):
|
def test_conflict_when_network_iface_uncovered(self):
|
||||||
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
|
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") as mock_run,
|
||||||
|
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||||
|
patch("daemon.handlers.firewall._save_backup") as mock_backup,
|
||||||
|
pytest.raises(ConflictError) as exc,
|
||||||
|
):
|
||||||
|
daemonfirewall._config_apply()
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "eth0" in msg
|
||||||
|
assert "unmanaged" in msg
|
||||||
|
assert "force" in msg
|
||||||
|
mock_backup.assert_not_called()
|
||||||
|
# Pure config check: the guard performs no live-state reads at all.
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
def test_unmanaged_exempts_iface(self):
|
||||||
|
cfg = {
|
||||||
|
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
|
||||||
|
"unmanaged": ["eth0"],
|
||||||
|
}
|
||||||
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
|
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
|
||||||
assert result["applied_zones"] == ["public"]
|
assert result["applied_zones"] == ["public"]
|
||||||
# The guard reads live zones once, up front.
|
cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||||
assert mock_run.call_args_list[0].args[0] == [
|
# Live eth0 is removed, config eth1 added exactly once (permanent).
|
||||||
|
assert [
|
||||||
"firewall-cmd",
|
"firewall-cmd",
|
||||||
"--get-active-zones",
|
"--zone=public",
|
||||||
]
|
"--remove-interface=eth0",
|
||||||
# Hands off: no interface mutation commands for the absent-key zone.
|
"--permanent",
|
||||||
for c in mock_run.call_args_list:
|
] in cmds
|
||||||
for arg in c.args[0]:
|
assert (
|
||||||
assert not arg.startswith("--remove-interface=")
|
cmds.count(
|
||||||
assert not arg.startswith("--add-interface=")
|
["firewall-cmd", "--zone=public", "--add-interface=eth1", "--permanent"]
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_absent_key_zone_counts_as_empty(self):
|
||||||
|
# No hands-off zones: a zone without an 'interfaces' key covers
|
||||||
|
# nothing, so a managed interface left out of every zone blocks.
|
||||||
|
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
|
||||||
|
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"),
|
||||||
|
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||||
|
pytest.raises(ConflictError) as exc,
|
||||||
|
):
|
||||||
|
daemonfirewall._config_apply()
|
||||||
|
assert "eth0" in str(exc.value)
|
||||||
|
|
||||||
|
def test_live_only_zone_does_not_count_as_covered(self):
|
||||||
|
# eth1 is held by 'guest' live but the config (the source of
|
||||||
|
# truth) does not cover it — apply is blocked regardless of live.
|
||||||
|
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
||||||
|
with (
|
||||||
|
patch("lib.network.get_config", return_value={"interfaces": {"eth1": {}}}),
|
||||||
|
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.firewall.run",
|
||||||
|
side_effect=_make_run("public\n eth0\nguest\n eth1\n"),
|
||||||
|
),
|
||||||
|
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||||
|
pytest.raises(ConflictError),
|
||||||
|
):
|
||||||
|
daemonfirewall._config_apply()
|
||||||
|
|
||||||
|
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_explicit_empty_list_unassigns(self):
|
def test_explicit_empty_list_unassigns(self):
|
||||||
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
||||||
@@ -891,60 +1007,80 @@ class TestDaemonInterfaceCoverageGuard:
|
|||||||
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds
|
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": []}}}
|
class TestDaemonSaveCoverageValidation:
|
||||||
|
"""The coverage invariant is enforced at save time too (POST/PATCH
|
||||||
|
/firewall/config), so bad configs are rejected before they are written."""
|
||||||
|
|
||||||
|
def test_save_blocks_uncovered(self):
|
||||||
|
body = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
||||||
with (
|
with (
|
||||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||||
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||||
patch(
|
pytest.raises(ValueError) as exc,
|
||||||
"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()
|
daemonfirewall.save_config_handler(None, body)
|
||||||
assert "eth0" in str(exc.value)
|
assert "eth0" in str(exc.value)
|
||||||
assert "force" in str(exc.value)
|
assert "unmanaged" in str(exc.value)
|
||||||
|
mock_save.assert_not_called()
|
||||||
|
|
||||||
def test_force_bypasses_coverage_guard(self):
|
def test_save_allows_unmanaged(self):
|
||||||
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
body = {
|
||||||
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
|
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
|
||||||
assert result["applied_zones"] == ["public"]
|
"unmanaged": ["eth0"],
|
||||||
|
}
|
||||||
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 (
|
with (
|
||||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||||
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||||
patch(
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
"daemon.handlers.firewall.run",
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
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_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
mock_backup.assert_not_called()
|
result = daemonfirewall.save_config_handler(None, body)
|
||||||
# Only the guard's live-zone read ran — no mutation commands at all.
|
assert result == {"config_saved": True}
|
||||||
assert [c.args[0] for c in mock_run.call_args_list] == [
|
mock_save.assert_called_once()
|
||||||
["firewall-cmd", "--get-active-zones"]
|
|
||||||
]
|
def test_save_rejects_non_list_unmanaged(self):
|
||||||
|
body = {"zones": {"public": {"interfaces": ["eth0"]}}, "unmanaged": "eth0"}
|
||||||
|
with (
|
||||||
|
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||||
|
pytest.raises(ValueError) as exc,
|
||||||
|
):
|
||||||
|
daemonfirewall.save_config_handler(None, body)
|
||||||
|
assert "unmanaged" in str(exc.value)
|
||||||
|
|
||||||
|
def test_patch_blocks_merge_that_uncovers(self):
|
||||||
|
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
|
||||||
|
body = {"zones": {"public": {"interfaces": ["eth1"]}}}
|
||||||
|
with (
|
||||||
|
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
|
||||||
|
),
|
||||||
|
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||||
|
pytest.raises(ValueError) as exc,
|
||||||
|
):
|
||||||
|
daemonfirewall.patch_config(None, body)
|
||||||
|
assert "eth0" in str(exc.value)
|
||||||
|
mock_save.assert_not_called()
|
||||||
|
|
||||||
|
def test_patch_allows_merge_that_covers(self):
|
||||||
|
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
|
||||||
|
body = {"zones": {"internal": {"interfaces": ["eth1"], "services": []}}}
|
||||||
|
with (
|
||||||
|
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
|
||||||
|
),
|
||||||
|
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||||
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
|
):
|
||||||
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
|
result = daemonfirewall.patch_config(None, body)
|
||||||
|
assert result == {"config_saved": True}
|
||||||
|
saved = mock_save.call_args[0][0]
|
||||||
|
assert set(saved["zones"]) == {"public", "internal"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -957,8 +1093,8 @@ class TestDaemonCreateZone:
|
|||||||
with (
|
with (
|
||||||
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
|
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
|
||||||
patch.object(daemonfirewall, "_reload"),
|
patch.object(daemonfirewall, "_reload"),
|
||||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
):
|
):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
result = daemonfirewall.create_zone(None, body)
|
result = daemonfirewall.create_zone(None, body)
|
||||||
@@ -1023,7 +1159,7 @@ class TestDaemonConfigApplyBackup:
|
|||||||
"daemon.handlers.firewall._save_backup",
|
"daemon.handlers.firewall._save_backup",
|
||||||
return_value="/tmp/rules.json",
|
return_value="/tmp/rules.json",
|
||||||
) as mock_backup,
|
) as mock_backup,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
|
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
|
||||||
patch("daemon.handlers.firewall._save_config"),
|
patch("daemon.handlers.firewall._save_config"),
|
||||||
):
|
):
|
||||||
@@ -1082,7 +1218,7 @@ class TestDaemonConfigApplyStamp:
|
|||||||
"daemon.handlers.firewall._get_state",
|
"daemon.handlers.firewall._get_state",
|
||||||
return_value={"zones": {"public": {}}},
|
return_value={"zones": {"public": {}}},
|
||||||
),
|
),
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
patch(
|
patch(
|
||||||
"daemon.handlers.firewall._get_config",
|
"daemon.handlers.firewall._get_config",
|
||||||
return_value=deepcopy(_STAMP_TEST_CFG),
|
return_value=deepcopy(_STAMP_TEST_CFG),
|
||||||
@@ -1114,8 +1250,8 @@ class TestDaemonMutatorBaselineStamp:
|
|||||||
def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
|
def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
|
||||||
with (
|
with (
|
||||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
):
|
):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
daemonfirewall.set_zone_interfaces(
|
daemonfirewall.set_zone_interfaces(
|
||||||
@@ -1138,8 +1274,8 @@ class TestDaemonMutatorBaselineStamp:
|
|||||||
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
||||||
),
|
),
|
||||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
):
|
):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
daemonfirewall.set_zone_services(
|
daemonfirewall.set_zone_services(
|
||||||
@@ -1162,8 +1298,8 @@ class TestDaemonMutatorBaselineStamp:
|
|||||||
):
|
):
|
||||||
with (
|
with (
|
||||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
):
|
):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
|
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
|
||||||
@@ -1182,8 +1318,8 @@ class TestDaemonMutatorBaselineStamp:
|
|||||||
would manufacture spurious service diffs on the next poll."""
|
would manufacture spurious service diffs on the next poll."""
|
||||||
with (
|
with (
|
||||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
patch.object(daemoncommon, "bus") as mock_bus,
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
patch("daemon.handlers.common.refresh_state"),
|
||||||
):
|
):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
|
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
|
||||||
@@ -1208,10 +1344,10 @@ class TestDaemonGetConfigEndpoint:
|
|||||||
"daemon.handlers.firewall._config_apply",
|
"daemon.handlers.firewall._config_apply",
|
||||||
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
||||||
)
|
)
|
||||||
@patch("daemon.handlers.firewall.bus")
|
@patch("daemon.handlers.common.bus")
|
||||||
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
|
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
|
||||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
with patch("daemon.handlers.firewall.refresh_state"):
|
with patch("daemon.handlers.common.refresh_state"):
|
||||||
daemonfirewall.config_apply(None, None)
|
daemonfirewall.config_apply(None, None)
|
||||||
mock_apply.assert_called_once_with(force=False)
|
mock_apply.assert_called_once_with(force=False)
|
||||||
mock_apply.reset_mock()
|
mock_apply.reset_mock()
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ class TestGetConfig:
|
|||||||
assert isinstance(cfg, dict)
|
assert isinstance(cfg, dict)
|
||||||
assert "interfaces" in cfg
|
assert "interfaces" in cfg
|
||||||
|
|
||||||
def test_creates_config_file(self, tmp_network):
|
def test_missing_file_returns_default_without_writing(self, tmp_network):
|
||||||
|
# Pure read: get_config never materializes the file.
|
||||||
cfg = _net.get_config()
|
cfg = _net.get_config()
|
||||||
assert _net.CONFIG_FILE.exists()
|
|
||||||
assert cfg["interfaces"] == {}
|
assert cfg["interfaces"] == {}
|
||||||
|
assert not _net.CONFIG_FILE.exists()
|
||||||
|
|
||||||
|
|
||||||
class TestSaveConfig:
|
class TestSaveConfig:
|
||||||
|
|||||||
@@ -279,18 +279,18 @@ class TestStateParserDedup:
|
|||||||
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
|
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
|
||||||
|
|
||||||
def test_state_uses_network_parser(self):
|
def test_state_uses_network_parser(self):
|
||||||
"""The networkd collector in state.py should import from lib.network."""
|
"""The networkd collector should import from lib.network."""
|
||||||
import lib.state as _state
|
import daemon.collectors.networkd as _collector
|
||||||
|
|
||||||
source = Path(_state.__file__).read_text()
|
source = Path(_collector.__file__).read_text()
|
||||||
assert "from lib.network import parse_networkctl_status" in source
|
assert "from lib.network import" in source
|
||||||
assert "parse_networkctl_status" in source
|
assert "parse_networkctl_status" in source
|
||||||
|
|
||||||
def test_networkd_collector_returns_correct_format(self):
|
def test_networkd_collector_returns_correct_format(self):
|
||||||
"""_collect_networkd should return interfaces dict + timestamp."""
|
"""_collect_networkd should return interfaces dict + timestamp."""
|
||||||
import lib.state as _state
|
import daemon.collectors.networkd as _collector
|
||||||
|
|
||||||
with patch("lib.state.run") as mock_run:
|
with patch("daemon.collectors.networkd.run") as mock_run:
|
||||||
mock_run.return_value = json.dumps(
|
mock_run.return_value = json.dumps(
|
||||||
{
|
{
|
||||||
"Interfaces": [
|
"Interfaces": [
|
||||||
@@ -320,7 +320,7 @@ class TestStateParserDedup:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = _state._collect_networkd()
|
result = _collector._collect_networkd()
|
||||||
|
|
||||||
assert "interfaces" in result
|
assert "interfaces" in result
|
||||||
assert "timestamp" in result
|
assert "timestamp" in result
|
||||||
@@ -329,10 +329,12 @@ class TestStateParserDedup:
|
|||||||
|
|
||||||
def test_networkd_collector_handles_failure(self):
|
def test_networkd_collector_handles_failure(self):
|
||||||
"""_collect_networkd returns empty interfaces on error."""
|
"""_collect_networkd returns empty interfaces on error."""
|
||||||
import lib.state as _state
|
import daemon.collectors.networkd as _collector
|
||||||
|
|
||||||
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
|
with patch(
|
||||||
result = _state._collect_networkd()
|
"daemon.collectors.networkd.run", side_effect=RuntimeError("no networkctl")
|
||||||
|
):
|
||||||
|
result = _collector._collect_networkd()
|
||||||
|
|
||||||
assert result["interfaces"] == {}
|
assert result["interfaces"] == {}
|
||||||
assert "timestamp" in result
|
assert "timestamp" in result
|
||||||
|
|||||||
+17
-3
@@ -70,14 +70,28 @@ class TestGetConfig:
|
|||||||
# No churn: reading a current-format config leaves the file alone.
|
# No churn: reading a current-format config leaves the file alone.
|
||||||
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
|
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
|
||||||
|
|
||||||
def test_read_saves_when_migration_applied(self, temp_data_dir):
|
def test_read_migrates_in_memory_without_writing(self, temp_data_dir):
|
||||||
"""get_config() persists the file when migration actually changes it."""
|
"""get_config() is pure: migration is applied in memory, file untouched."""
|
||||||
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
||||||
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||||
cfg = nginx.get_config()
|
cfg = nginx.get_config()
|
||||||
# Migration added the builtin webui backend.
|
# Migration added the builtin webui backend (in memory only).
|
||||||
assert cfg["backends"]["webui"]["_migrated"] is True
|
assert cfg["backends"]["webui"]["_migrated"] is True
|
||||||
|
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
|
||||||
|
|
||||||
|
def test_migrate_config_file_persists_legacy(self, temp_data_dir):
|
||||||
|
"""migrate_config_file() rewrites the file when migration changes it."""
|
||||||
|
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
||||||
|
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||||
|
assert nginx.migrate_config_file() is True
|
||||||
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
|
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
|
||||||
|
# Idempotent: a second run is a no-op.
|
||||||
|
assert nginx.migrate_config_file() is False
|
||||||
|
|
||||||
|
def test_migrate_config_file_noop_when_missing(self, temp_data_dir):
|
||||||
|
assert not nginx.CONFIG_FILE.exists()
|
||||||
|
assert nginx.migrate_config_file() is False
|
||||||
|
assert not nginx.CONFIG_FILE.exists()
|
||||||
|
|
||||||
|
|
||||||
class TestSaveConfig:
|
class TestSaveConfig:
|
||||||
|
|||||||
+25
-15
@@ -9,7 +9,13 @@ these tests catch drift between the schemas and the collectors.
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import lib.state
|
import daemon.collectors.acme
|
||||||
|
import daemon.collectors.dnsmasq
|
||||||
|
import daemon.collectors.firewall
|
||||||
|
import daemon.collectors.networkd
|
||||||
|
import daemon.collectors.nginx
|
||||||
|
import daemon.collectors.system
|
||||||
|
import daemon.collectors.wireguard
|
||||||
from lib import schema
|
from lib import schema
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +26,9 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
|
|||||||
class TestCollectorShapesMatchSchema:
|
class TestCollectorShapesMatchSchema:
|
||||||
def test_firewall_state(self):
|
def test_firewall_state(self):
|
||||||
with (
|
with (
|
||||||
patch.object(lib.state, "run") as mock_run,
|
patch.object(daemon.collectors.firewall, "run") as mock_run,
|
||||||
patch.object(
|
patch.object(
|
||||||
lib.state,
|
daemon.collectors.firewall,
|
||||||
"_network_get_config",
|
"_network_get_config",
|
||||||
return_value={
|
return_value={
|
||||||
"interfaces": {
|
"interfaces": {
|
||||||
@@ -63,7 +69,7 @@ class TestCollectorShapesMatchSchema:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
mock_run.side_effect = run_side
|
mock_run.side_effect = run_side
|
||||||
result = lib.state._collect_firewall()
|
result = daemon.collectors.firewall._collect_firewall()
|
||||||
|
|
||||||
assert not _missing(schema.FirewallState.__required_keys__, result)
|
assert not _missing(schema.FirewallState.__required_keys__, result)
|
||||||
for iface in result["interfaces"]:
|
for iface in result["interfaces"]:
|
||||||
@@ -74,38 +80,40 @@ class TestCollectorShapesMatchSchema:
|
|||||||
assert result["uncovered_interfaces"] == ["eth1"]
|
assert result["uncovered_interfaces"] == ["eth1"]
|
||||||
|
|
||||||
def test_dnsmasq_state(self):
|
def test_dnsmasq_state(self):
|
||||||
with patch.object(lib.state, "run_proc") as mock_proc:
|
with patch.object(daemon.collectors.dnsmasq, "run_proc") as mock_proc:
|
||||||
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||||
result = lib.state._collect_dnsmasq()
|
result = daemon.collectors.dnsmasq._collect_dnsmasq()
|
||||||
|
|
||||||
assert not _missing(schema.DnsmasqState.__required_keys__, result)
|
assert not _missing(schema.DnsmasqState.__required_keys__, result)
|
||||||
for k in schema.DnsmasqStatus.__required_keys__:
|
for k in schema.DnsmasqStatus.__required_keys__:
|
||||||
assert k in result["status"], f"DnsmasqStatus missing {k}"
|
assert k in result["status"], f"DnsmasqStatus missing {k}"
|
||||||
|
|
||||||
def test_nginx_state(self):
|
def test_nginx_state(self):
|
||||||
result = lib.state._collect_nginx()
|
result = daemon.collectors.nginx._collect_nginx()
|
||||||
assert not _missing(schema.NginxState.__required_keys__, result)
|
assert not _missing(schema.NginxState.__required_keys__, result)
|
||||||
assert "pending_changes" in result["status"]
|
assert "pending_changes" in result["status"]
|
||||||
|
|
||||||
def test_acme_state(self):
|
def test_acme_state(self):
|
||||||
with (
|
with (
|
||||||
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||||
|
),
|
||||||
patch("lib.acme.list_certs", return_value=[]),
|
patch("lib.acme.list_certs", return_value=[]),
|
||||||
patch.object(
|
patch.object(
|
||||||
lib.state,
|
daemon.collectors.acme,
|
||||||
"_parse_account_conf",
|
"_parse_account_conf",
|
||||||
return_value={"registered": False, "email": "", "ca": ""},
|
return_value={"registered": False, "email": "", "ca": ""},
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
result = lib.state._collect_acme()
|
result = daemon.collectors.acme._collect_acme()
|
||||||
|
|
||||||
assert not _missing(schema.AcmeState.__required_keys__, result)
|
assert not _missing(schema.AcmeState.__required_keys__, result)
|
||||||
assert result["status"]["error"] is None
|
assert result["status"]["error"] is None
|
||||||
|
|
||||||
def test_wireguard_state(self):
|
def test_wireguard_state(self):
|
||||||
with patch.object(lib.state, "run_proc") as mock_proc:
|
with patch.object(daemon.collectors.wireguard, "run_proc") as mock_proc:
|
||||||
mock_proc.return_value = Mock(stdout="", returncode=1)
|
mock_proc.return_value = Mock(stdout="", returncode=1)
|
||||||
result = lib.state._collect_wireguard()
|
result = daemon.collectors.wireguard._collect_wireguard()
|
||||||
|
|
||||||
assert not _missing(schema.WgState.__required_keys__, result)
|
assert not _missing(schema.WgState.__required_keys__, result)
|
||||||
for k in schema.WgStatus.__required_keys__:
|
for k in schema.WgStatus.__required_keys__:
|
||||||
@@ -135,8 +143,10 @@ class TestCollectorShapesMatchSchema:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
|
with patch.object(
|
||||||
result = lib.state._collect_networkd()
|
daemon.collectors.networkd, "run", return_value=json.dumps(networkctl)
|
||||||
|
):
|
||||||
|
result = daemon.collectors.networkd._collect_networkd()
|
||||||
|
|
||||||
assert not _missing(schema.NetworkdState.__required_keys__, result)
|
assert not _missing(schema.NetworkdState.__required_keys__, result)
|
||||||
assert "eth0" in result["interfaces"]
|
assert "eth0" in result["interfaces"]
|
||||||
@@ -148,7 +158,7 @@ class TestCollectorShapesMatchSchema:
|
|||||||
|
|
||||||
def test_system_state(self):
|
def test_system_state(self):
|
||||||
"""Reads /proc and /sys directly — no mocking needed on Linux."""
|
"""Reads /proc and /sys directly — no mocking needed on Linux."""
|
||||||
result = lib.state._collect_system()
|
result = daemon.collectors.system._collect_system()
|
||||||
assert not _missing(schema.SystemState.__required_keys__, result)
|
assert not _missing(schema.SystemState.__required_keys__, result)
|
||||||
for k in schema.CpuLoad.__required_keys__:
|
for k in schema.CpuLoad.__required_keys__:
|
||||||
assert k in result["load"], f"CpuLoad missing {k}"
|
assert k in result["load"], f"CpuLoad missing {k}"
|
||||||
|
|||||||
+31
-19
@@ -3,7 +3,9 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import lib
|
import daemon.collectors.acme
|
||||||
|
import daemon.collectors.dnsmasq
|
||||||
|
import daemon.collectors.firewall
|
||||||
from lib.state import State, state
|
from lib.state import State, state
|
||||||
|
|
||||||
|
|
||||||
@@ -51,9 +53,9 @@ class TestState:
|
|||||||
|
|
||||||
|
|
||||||
class TestCollectAll:
|
class TestCollectAll:
|
||||||
@patch("lib.state.run")
|
@patch("daemon.collectors.firewall.run")
|
||||||
def test_collect_firewall_returns_dict(self, mock_run):
|
def test_collect_firewall_returns_dict(self, mock_run):
|
||||||
from lib.state import _collect_firewall
|
from daemon.collectors.firewall import _collect_firewall
|
||||||
|
|
||||||
def run_side(args, **kwargs):
|
def run_side(args, **kwargs):
|
||||||
if "--get-active-zones" in args:
|
if "--get-active-zones" in args:
|
||||||
@@ -88,10 +90,10 @@ class TestCollectAll:
|
|||||||
assert "interfaces" in result
|
assert "interfaces" in result
|
||||||
assert "timestamp" in result
|
assert "timestamp" in result
|
||||||
|
|
||||||
@patch("lib.state.run")
|
@patch("daemon.collectors.firewall.run")
|
||||||
def test_collect_firewall_vlan_ips_populated(self, mock_run):
|
def test_collect_firewall_vlan_ips_populated(self, mock_run):
|
||||||
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
|
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
|
||||||
from lib.state import _collect_firewall
|
from daemon.collectors.firewall import _collect_firewall
|
||||||
|
|
||||||
def run_side(args, **kwargs):
|
def run_side(args, **kwargs):
|
||||||
if "--get-active-zones" in args:
|
if "--get-active-zones" in args:
|
||||||
@@ -144,10 +146,10 @@ class TestCollectAll:
|
|||||||
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
|
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
|
||||||
assert "10.0.0.1/24" in vlan_iface["ips"]
|
assert "10.0.0.1/24" in vlan_iface["ips"]
|
||||||
|
|
||||||
@patch("lib.state.get_service_descriptions")
|
@patch("daemon.collectors.firewall.get_service_descriptions")
|
||||||
@patch("lib.state.run")
|
@patch("daemon.collectors.firewall.run")
|
||||||
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
|
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
|
||||||
from lib.state import _collect_firewall
|
from daemon.collectors.firewall import _collect_firewall
|
||||||
|
|
||||||
def run_side(args, **kwargs):
|
def run_side(args, **kwargs):
|
||||||
if "--get-active-zones" in args:
|
if "--get-active-zones" in args:
|
||||||
@@ -168,11 +170,11 @@ class TestCollectAll:
|
|||||||
mock_desc.assert_called_once_with()
|
mock_desc.assert_called_once_with()
|
||||||
assert result["service_descriptions"] == descs
|
assert result["service_descriptions"] == descs
|
||||||
|
|
||||||
@patch("lib.state.run_proc")
|
@patch("daemon.collectors.dnsmasq.run_proc")
|
||||||
def test_collect_dnsmasq_returns_dict(self, mock_proc):
|
def test_collect_dnsmasq_returns_dict(self, mock_proc):
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from lib.state import _collect_dnsmasq
|
from daemon.collectors.dnsmasq import _collect_dnsmasq
|
||||||
|
|
||||||
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||||
result = _collect_dnsmasq()
|
result = _collect_dnsmasq()
|
||||||
@@ -181,12 +183,12 @@ class TestCollectAll:
|
|||||||
assert "config" in result
|
assert "config" in result
|
||||||
assert "leases" in result
|
assert "leases" in result
|
||||||
|
|
||||||
@patch("lib.state.run_proc")
|
@patch("daemon.collectors.dnsmasq.run_proc")
|
||||||
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
|
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from daemon.collectors.dnsmasq import _collect_dnsmasq
|
||||||
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
|
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
|
||||||
from lib.state import _collect_dnsmasq
|
|
||||||
|
|
||||||
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
|
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
|
||||||
applied = {
|
applied = {
|
||||||
@@ -226,7 +228,9 @@ class TestCollectAll:
|
|||||||
_APPLY_HASH_KEY: "stale-hash",
|
_APPLY_HASH_KEY: "stale-hash",
|
||||||
}
|
}
|
||||||
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
|
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
|
||||||
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
|
monkeypatch.setattr(
|
||||||
|
"lib.dnsmasq.CONFIG_PATH", tmp_path / "config" / "dnsmasq" / "config.json"
|
||||||
|
)
|
||||||
# service check -> active; lease file read -> no lines
|
# service check -> active; lease file read -> no lines
|
||||||
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||||
|
|
||||||
@@ -257,15 +261,19 @@ class TestAcmeCollectNonFatal:
|
|||||||
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
|
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
|
||||||
|
|
||||||
def test_list_failure_yields_empty_certs_and_error(self):
|
def test_list_failure_yields_empty_certs_and_error(self):
|
||||||
from lib.state import _collect_acme
|
from daemon.collectors.acme import _collect_acme
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"lib.acme.list_certs",
|
"lib.acme.list_certs",
|
||||||
side_effect=RuntimeError("acme.sh failed with exit code 2"),
|
side_effect=RuntimeError("acme.sh failed with exit code 2"),
|
||||||
),
|
),
|
||||||
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||||
|
),
|
||||||
):
|
):
|
||||||
result = _collect_acme()
|
result = _collect_acme()
|
||||||
|
|
||||||
@@ -275,12 +283,16 @@ class TestAcmeCollectNonFatal:
|
|||||||
assert "exit code 2" in result["status"]["error"]
|
assert "exit code 2" in result["status"]["error"]
|
||||||
|
|
||||||
def test_success_reports_no_error(self):
|
def test_success_reports_no_error(self):
|
||||||
from lib.state import _collect_acme
|
from daemon.collectors.acme import _collect_acme
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||||
|
),
|
||||||
patch("lib.acme.list_certs", return_value=[]),
|
patch("lib.acme.list_certs", return_value=[]),
|
||||||
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||||
|
),
|
||||||
):
|
):
|
||||||
result = _collect_acme()
|
result = _collect_acme()
|
||||||
|
|
||||||
|
|||||||
+38
-2
@@ -336,7 +336,7 @@ class TestGenerateWgShowParser:
|
|||||||
" listening port: 51820\n"
|
" listening port: 51820\n"
|
||||||
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
|
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
|
||||||
)
|
)
|
||||||
result = wireguard._parse_wg_show_output(output)
|
result = wireguard.parse_wg_show_output(output)
|
||||||
assert result["up"] is True
|
assert result["up"] is True
|
||||||
assert result["interface"]["public_key"] == "IFACE-PUB"
|
assert result["interface"]["public_key"] == "IFACE-PUB"
|
||||||
assert result["interface"]["listen_port"] == 51820
|
assert result["interface"]["listen_port"] == 51820
|
||||||
@@ -346,10 +346,46 @@ class TestGenerateWgShowParser:
|
|||||||
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
|
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
|
||||||
|
|
||||||
def test_empty_output(self):
|
def test_empty_output(self):
|
||||||
result = wireguard._parse_wg_show_output("")
|
result = wireguard.parse_wg_show_output("")
|
||||||
assert result["up"] is False
|
assert result["up"] is False
|
||||||
assert result["peers"] == []
|
assert result["peers"] == []
|
||||||
|
|
||||||
|
def test_parses_fwmark(self):
|
||||||
|
output = (
|
||||||
|
"interface: wg0\n"
|
||||||
|
" public key: IFACE-PUB\n"
|
||||||
|
" listening port: 51820\n"
|
||||||
|
" fwmark: 0x0\n"
|
||||||
|
)
|
||||||
|
result = wireguard.parse_wg_show_output(output)
|
||||||
|
assert result["up"] is True
|
||||||
|
assert result["interface"]["fwmark"] == "0x0"
|
||||||
|
|
||||||
|
def test_peer_transfer_and_keepalive(self):
|
||||||
|
output = (
|
||||||
|
"interface: wg0\n"
|
||||||
|
" public key: IFACE-PUB\n"
|
||||||
|
" listening port: 51820\n"
|
||||||
|
" peer: PUBKEY1\n"
|
||||||
|
" endpoint: 203.0.113.1:51820\n"
|
||||||
|
" allowed ips: 10.0.0.0/24, 10.0.1.0/24\n"
|
||||||
|
" latest handshake: 2 minutes ago\n"
|
||||||
|
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
|
||||||
|
" persistent-keepalive: 25\n"
|
||||||
|
)
|
||||||
|
result = wireguard.parse_wg_show_output(output)
|
||||||
|
peer = result["peers"][0]
|
||||||
|
assert peer["allowed_ips"] == ["10.0.0.0/24", "10.0.1.0/24"]
|
||||||
|
assert peer["latest_handshake"] == "2 minutes ago"
|
||||||
|
assert peer["transfer_received"] == "1.23 GiB received"
|
||||||
|
assert peer["transfer_sent"] == "4.56 GiB sent"
|
||||||
|
assert peer["persistent_keepalive"] == 25
|
||||||
|
|
||||||
|
def test_bad_keepalive_value(self):
|
||||||
|
output = "interface: wg0\n peer: PUBKEY1\n persistent-keepalive: bogus\n"
|
||||||
|
result = wireguard.parse_wg_show_output(output)
|
||||||
|
assert result["peers"][0]["persistent_keepalive"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestAccessClasses:
|
class TestAccessClasses:
|
||||||
def test_default_config_has_access_classes(self):
|
def test_default_config_has_access_classes(self):
|
||||||
|
|||||||
+75
-239
@@ -3,11 +3,11 @@
|
|||||||
Exposed at /api/certs/* and delegates to vacuum-walld.
|
Exposed at /api/certs/* and delegates to vacuum-walld.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, post
|
from daemon.client import delete, get, post # noqa: F401 (resolved via module globals)
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
||||||
DELETE_ACME_REMOVE,
|
DELETE_ACME_REMOVE,
|
||||||
@@ -22,269 +22,105 @@ from daemon.iface import (
|
|||||||
POST_ACME_RENEW,
|
POST_ACME_RENEW,
|
||||||
POST_ACME_VALIDATE,
|
POST_ACME_VALIDATE,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import NO_BODY, daemon_route, void_transform
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("certs", __name__)
|
bp = Blueprint("certs", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/list", methods=["GET"])
|
def _validate_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def list_certs_bp():
|
domain = ((request.get_json(silent=True) or {}).get("domain") or "").strip()
|
||||||
"""GET /api/certs/list — list all managed ACME certificates.
|
if not domain:
|
||||||
|
raise ValueError("'domain' is required")
|
||||||
Returns:
|
return {"domain": domain}
|
||||||
Response containing the list of certificates or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_ACME_LIST))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list certificates: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<domain>", methods=["GET"])
|
def _issue_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def cert_details(domain: str):
|
|
||||||
"""GET /api/certs/<domain> — get details for a specific certificate.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
domain: Domain name to look up.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response containing certificate info or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_ACME_INFO, {"domain": domain}))
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Cert for '%s' not found: %s", domain, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get cert info for '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/validate", methods=["POST"])
|
|
||||||
def validate():
|
|
||||||
"""POST /api/certs/validate — run pre-flight checks for certificate issuance.
|
|
||||||
|
|
||||||
Expects JSON body with ``{``domain``}``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response containing validation results or an error message.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
domain = (body.get("domain") or "").strip()
|
domain = (body.get("domain") or "").strip()
|
||||||
if not domain:
|
if not domain:
|
||||||
return _error("'domain' is required", 400)
|
raise ValueError("'domain' is required")
|
||||||
try:
|
|
||||||
result = post(POST_ACME_VALIDATE, {"domain": domain})
|
|
||||||
return _ok(result)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Validation rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to validate cert for '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/issue/start", methods=["POST"])
|
|
||||||
def issue_start():
|
|
||||||
"""POST /api/certs/issue/start — create a new certificate issuance request.
|
|
||||||
|
|
||||||
Expects JSON body with ``{``domain``}``; optional ``email`` and ``webroot``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response containing an issuance request ID or an error message.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
domain = (body.get("domain") or "").strip()
|
|
||||||
if not domain:
|
|
||||||
return _error("'domain' is required", 400)
|
|
||||||
email = (body.get("email") or "").strip() or None
|
email = (body.get("email") or "").strip() or None
|
||||||
webroot = body.get("webroot")
|
return {"domain": domain, "webroot": body.get("webroot"), "email": email}
|
||||||
try:
|
|
||||||
logger.info("Certificate issuance requested for '%s' via API", domain)
|
|
||||||
result = post(
|
|
||||||
POST_ACME_ISSUE, {"domain": domain, "webroot": webroot, "email": email}
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Certificate issuance started for '%s' (id=%s)",
|
|
||||||
domain,
|
|
||||||
result.get("request_id"),
|
|
||||||
)
|
|
||||||
return _ok(result)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except Conflict as exc:
|
|
||||||
return _error(str(exc), 409)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/issue/<request_id>", methods=["GET"])
|
def _email_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def issue_status(request_id: str):
|
email = ((request.get_json(silent=True) or {}).get("email") or "").strip()
|
||||||
"""GET /api/certs/issue/<request_id> — poll status of a certificate issuance request.
|
if not email:
|
||||||
|
raise ValueError("'email' is required")
|
||||||
Args:
|
return {"email": email}
|
||||||
request_id: Issuance request identifier returned by issue_start.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response containing issuance status or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = get(GET_ACME_ISSUE_STATUS, {"id": request_id})
|
|
||||||
return _ok(result)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Issuance request '%s' not found: %s", request_id, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get issuance status for '%s': %s", request_id, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<domain>/renew", methods=["POST"])
|
def _register_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def renew_bp(domain: str):
|
body = request.get_json(silent=True) or {}
|
||||||
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
|
email = (body.get("email") or "").strip()
|
||||||
|
if not email:
|
||||||
Returns:
|
raise ValueError("'email' is required")
|
||||||
Response containing a renewal request ID (poll it at
|
return {"email": email, "server": (body.get("server") or "").strip()}
|
||||||
``/api/certs/renew/<request_id>``) or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info("Certificate renewal requested for '%s' via API", domain)
|
|
||||||
result = post(POST_ACME_RENEW, {"domain": domain})
|
|
||||||
logger.info(
|
|
||||||
"Certificate renewal started for '%s' (id=%s)",
|
|
||||||
domain,
|
|
||||||
result.get("request_id"),
|
|
||||||
)
|
|
||||||
return _ok(result)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to renew cert for '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/renew/<request_id>", methods=["GET"])
|
def _email_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
def renew_status(request_id: str):
|
return {"email": sent["email"]}
|
||||||
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request_id: Renewal request identifier returned by renew_bp.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response containing renewal status or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
|
|
||||||
return _ok(result)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Renewal request '%s' not found: %s", request_id, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<domain>", methods=["DELETE"])
|
@daemon_route(GET_ACME_LIST, bp)
|
||||||
def remove_bp(domain: str):
|
def list_certs_bp():
|
||||||
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
|
"""GET /api/certs/list — List all managed ACME certificates."""
|
||||||
|
|
||||||
Args:
|
|
||||||
domain: Domain name whose certificate should be removed.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response confirming removal or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
delete(DELETE_ACME_REMOVE, {"domain": domain})
|
|
||||||
logger.info("Certificate removed for '%s' via API", domain)
|
|
||||||
return _ok(None)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Cert '%s' not found: %s", domain, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove cert '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/email", methods=["POST"])
|
@daemon_route(GET_ACME_INFO, bp, rule="/<domain>")
|
||||||
|
def cert_details():
|
||||||
|
"""GET /api/certs/<domain> — Get details for a specific certificate."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_ACME_VALIDATE, bp, body=_validate_body)
|
||||||
|
def validate():
|
||||||
|
"""POST /api/certs/validate — Run pre-flight checks for issuance."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_ACME_ISSUE, bp, rule="/issue/start", body=_issue_body)
|
||||||
|
def issue_start():
|
||||||
|
"""POST /api/certs/issue/start — Create a new certificate issuance request."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
GET_ACME_ISSUE_STATUS, bp, rule="/issue/<request_id>", params={"id": "request_id"}
|
||||||
|
)
|
||||||
|
def issue_status():
|
||||||
|
"""GET /api/certs/issue/<request_id> — Poll status of an issuance request."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_ACME_RENEW, bp, rule="/<domain>/renew")
|
||||||
|
def renew_bp():
|
||||||
|
"""POST /api/certs/<domain>/renew — Start an (async) certificate renewal."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
GET_ACME_RENEW_STATUS, bp, rule="/renew/<request_id>", params={"id": "request_id"}
|
||||||
|
)
|
||||||
|
def renew_status():
|
||||||
|
"""GET /api/certs/renew/<request_id> — Poll status of a certificate renewal."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(DELETE_ACME_REMOVE, bp, rule="/<domain>", transform=void_transform)
|
||||||
|
def remove_bp():
|
||||||
|
"""DELETE /api/certs/<domain> — Remove a certificate from ACME management."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_ACME_EMAIL, bp, body=_email_body, transform=_email_echo)
|
||||||
def set_email_bp():
|
def set_email_bp():
|
||||||
"""POST /api/certs/email — set the ACME account email address.
|
"""POST /api/certs/email — Set the ACME account email address."""
|
||||||
|
|
||||||
Expects JSON body with ``{``email``}``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response confirming the email was set or an error message.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
email = (body.get("email") or "").strip()
|
|
||||||
if not email:
|
|
||||||
return _error("'email' is required", 400)
|
|
||||||
try:
|
|
||||||
post(POST_ACME_EMAIL, {"email": email})
|
|
||||||
logger.info("ACME email set via API: %s", email)
|
|
||||||
return _ok({"email": email})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("ACME email set rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to set ACME email: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/account", methods=["GET"])
|
@daemon_route(GET_ACME_ACCOUNT, bp)
|
||||||
def account():
|
def account():
|
||||||
"""GET /api/certs/account — return ACME account information.
|
"""GET /api/certs/account — Return ACME account information."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response containing account status or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = get(GET_ACME_ACCOUNT)
|
|
||||||
return _ok(result)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get ACME account: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/account/register", methods=["POST"])
|
@daemon_route(POST_ACME_ACCOUNT_REGISTER, bp, body=_register_body)
|
||||||
def register_account():
|
def register_account():
|
||||||
"""POST /api/certs/account/register — register a new ACME account.
|
"""POST /api/certs/account/register — Register a new ACME account."""
|
||||||
|
|
||||||
Expects JSON body with ``{``email``, ``server``?}``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response confirming registration or an error message.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
email = (body.get("email") or "").strip()
|
|
||||||
if not email:
|
|
||||||
return _error("'email' is required", 400)
|
|
||||||
server = (body.get("server") or "").strip()
|
|
||||||
try:
|
|
||||||
result = post(POST_ACME_ACCOUNT_REGISTER, {"email": email, "server": server})
|
|
||||||
return _ok(result)
|
|
||||||
except BadRequest as exc:
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to register ACME account: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/account", methods=["DELETE"])
|
@daemon_route(DELETE_ACME_ACCOUNT_DEACTIVATE, bp, rule="/account", body=NO_BODY)
|
||||||
def deactivate_account():
|
def deactivate_account():
|
||||||
"""DELETE /api/certs/account — deactivate the ACME account.
|
"""DELETE /api/certs/account — Deactivate the ACME account."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response confirming deactivation or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = delete(DELETE_ACME_ACCOUNT_DEACTIVATE)
|
|
||||||
return _ok(result)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to deactivate ACME account: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
+185
-6
@@ -1,14 +1,36 @@
|
|||||||
"""Shared API response helpers.
|
"""Shared API response helpers + daemon-proxy route factory.
|
||||||
|
|
||||||
Used by all API blueprints to produce consistent JSON responses
|
Used by all API blueprints to produce consistent JSON responses per the
|
||||||
per the API response contract: ``{"ok": true, "data": <value>}`` /
|
API response contract (``{"ok": true, "data": <value>}`` /
|
||||||
``{"ok": false, "error": "msg"}``.
|
``{"ok": false, "error": "msg"}``) and to collapse the repetitive
|
||||||
|
``try: _ok(verb(EP, body)) except <typed> -> <code>`` boilerplate into a
|
||||||
|
single declarative ``daemon_route`` decorator.
|
||||||
|
|
||||||
|
The factory dispatches to the ``daemon.client`` verb imported into the
|
||||||
|
blueprint's own module namespace (resolved via ``sys.modules`` at request
|
||||||
|
time) so that tests can patch ``webui.api.<bp>.{get,post,patch,delete}``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import jsonify
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from daemon.client import BadRequest, Conflict, NotFound
|
||||||
|
from daemon.iface import Endpoint
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Sentinel: send the verb with NO body argument (``verb(endpoint)``).
|
||||||
|
NO_BODY = object()
|
||||||
|
|
||||||
|
Verb = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
def _ok(data=None):
|
def _ok(data: Any = None):
|
||||||
"""Return a success JSON response."""
|
"""Return a success JSON response."""
|
||||||
return jsonify({"ok": True, "data": data})
|
return jsonify({"ok": True, "data": data})
|
||||||
|
|
||||||
@@ -16,3 +38,160 @@ def _ok(data=None):
|
|||||||
def _error(msg: str, code: int = 400):
|
def _error(msg: str, code: int = 400):
|
||||||
"""Return an error JSON response with the given HTTP status code."""
|
"""Return an error JSON response with the given HTTP status code."""
|
||||||
return jsonify({"ok": False, "error": msg}), code
|
return jsonify({"ok": False, "error": msg}), code
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_rule(path: str) -> str:
|
||||||
|
"""Derive the Flask rule (relative to the blueprint url_prefix) from a
|
||||||
|
daemon endpoint path by dropping the leading subsystem segment.
|
||||||
|
|
||||||
|
``/firewall/zones`` -> ``/zones``; ``/acme/issue/status`` ->
|
||||||
|
``/issue/status``.
|
||||||
|
"""
|
||||||
|
parts = path.lstrip("/").split("/")
|
||||||
|
if len(parts) <= 1:
|
||||||
|
return "/"
|
||||||
|
return "/" + "/".join(parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _map_view_args(
|
||||||
|
view_args: dict[str, Any], params: dict[str, str] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Map Flask view args onto daemon body keys.
|
||||||
|
|
||||||
|
``params`` is a ``{body_key: view_arg_name}`` rename table. Any view arg
|
||||||
|
not listed maps to itself (identity), so path params are always
|
||||||
|
forwarded and only renamed where the daemon expects a different key.
|
||||||
|
"""
|
||||||
|
result = dict(view_args)
|
||||||
|
for body_key, view_arg_name in (params or {}).items():
|
||||||
|
result.pop(view_arg_name, None)
|
||||||
|
result[body_key] = view_args[view_arg_name]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def daemon_route(
|
||||||
|
endpoint: Endpoint,
|
||||||
|
bp: Blueprint,
|
||||||
|
rule: str | None = None,
|
||||||
|
methods: tuple[str, ...] | list[str] | None = None,
|
||||||
|
*,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
precheck: Callable[[Any, dict[str, Any]], None] | None = None,
|
||||||
|
body: Any | None = None,
|
||||||
|
transform: Callable[[Any, dict[str, Any], Any], Any] | None = None,
|
||||||
|
) -> Callable[..., Any]:
|
||||||
|
"""Decorator factory for thin daemon-proxy routes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: ``daemon.iface`` ``(method, path)`` tuple; ``endpoint[0]``
|
||||||
|
is the daemon HTTP verb.
|
||||||
|
bp: The target blueprint.
|
||||||
|
rule: Flask rule relative to the blueprint ``url_prefix``. Defaults
|
||||||
|
to the endpoint path minus its leading subsystem segment.
|
||||||
|
methods: Flask HTTP method(s). Defaults to ``[endpoint[0]]``;
|
||||||
|
override where the UI verb differs from the daemon verb
|
||||||
|
(e.g. a UI ``PUT`` that maps to a daemon ``POST``).
|
||||||
|
params: ``{body_key: view_arg_name}`` renames for path params.
|
||||||
|
precheck: ``(json, view_args) -> None`` run before dispatch; raise
|
||||||
|
``ValueError``/``BadRequest`` for a 400 (preserves webui-side
|
||||||
|
validation the daemon does not perform). ``json`` is the raw
|
||||||
|
``request.get_json(silent=True)`` result.
|
||||||
|
body: How to build the daemon request body for non-GET verbs.
|
||||||
|
``None`` (default): ``{**json, **mapped_view_args}``;
|
||||||
|
``NO_BODY``: send no body argument;
|
||||||
|
a callable ``(request, view_args) -> dict``: custom body (raise
|
||||||
|
``ValueError`` for 400);
|
||||||
|
a ``dict``: fixed body (merged with mapped view args).
|
||||||
|
transform: ``(data, view_args, sent_body) -> data`` applied to the
|
||||||
|
daemon result before wrapping in ``_ok()``; may raise a typed
|
||||||
|
exception to emit an error (e.g. 400 when a result is invalid).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A decorator that registers the route and returns a view function
|
||||||
|
whose ``__name__``/``__doc__`` are inherited from the decorated
|
||||||
|
function.
|
||||||
|
"""
|
||||||
|
if rule is None:
|
||||||
|
rule = _derive_rule(endpoint[1])
|
||||||
|
if methods is None:
|
||||||
|
methods = [endpoint[0]]
|
||||||
|
daemon_method = endpoint[0]
|
||||||
|
|
||||||
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
# The decorated view lives in the blueprint's module; its ``__globals__``
|
||||||
|
# is that module's namespace. Resolving the daemon verb here (instead of
|
||||||
|
# ``daemon.client`` directly) means tests patching
|
||||||
|
# ``webui.api.<bp>.{get,post,patch,delete}`` intercept the dispatch.
|
||||||
|
module_globals = fn.__globals__
|
||||||
|
|
||||||
|
def view(**view_args: Any) -> Any:
|
||||||
|
try:
|
||||||
|
json: Any = request.get_json(silent=True)
|
||||||
|
if precheck is not None:
|
||||||
|
precheck(json, view_args)
|
||||||
|
mapped = _map_view_args(view_args, params)
|
||||||
|
verb_fn: Verb = module_globals[daemon_method.lower()]
|
||||||
|
if daemon_method == "GET":
|
||||||
|
sent_body = mapped
|
||||||
|
data = (
|
||||||
|
verb_fn(endpoint, sent_body) if sent_body else verb_fn(endpoint)
|
||||||
|
)
|
||||||
|
elif body is NO_BODY:
|
||||||
|
sent_body = None
|
||||||
|
data = verb_fn(endpoint)
|
||||||
|
elif callable(body):
|
||||||
|
built = body(request, view_args)
|
||||||
|
sent_body = built
|
||||||
|
data = (
|
||||||
|
verb_fn(endpoint, built)
|
||||||
|
if built is not None
|
||||||
|
else verb_fn(endpoint)
|
||||||
|
)
|
||||||
|
elif isinstance(body, dict):
|
||||||
|
sent_body = {**body, **mapped}
|
||||||
|
data = verb_fn(endpoint, sent_body)
|
||||||
|
else:
|
||||||
|
base = json if isinstance(json, dict) else {}
|
||||||
|
sent_body = {**base, **mapped}
|
||||||
|
data = verb_fn(endpoint, sent_body)
|
||||||
|
if transform is not None:
|
||||||
|
data = transform(data, view_args, sent_body)
|
||||||
|
return _ok(data)
|
||||||
|
except (BadRequest, ValueError) as exc:
|
||||||
|
return _error(str(exc), 400)
|
||||||
|
except NotFound as exc:
|
||||||
|
logger.info("daemon 404 for %s %s: %s", daemon_method, endpoint[1], exc)
|
||||||
|
return _error(str(exc), 404)
|
||||||
|
except Conflict as exc:
|
||||||
|
logger.info("daemon 409 for %s %s: %s", daemon_method, endpoint[1], exc)
|
||||||
|
return _error(str(exc), 409)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error(
|
||||||
|
"daemon error for %s %s: %s", daemon_method, endpoint[1], exc
|
||||||
|
)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
view.__name__ = fn.__name__
|
||||||
|
view.__doc__ = fn.__doc__
|
||||||
|
bp.add_url_rule(rule, view_func=view, methods=list(methods))
|
||||||
|
return view
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def require_dict_body(json: Any, _view_args: dict[str, Any]) -> None:
|
||||||
|
"""Precheck: reject a non-dict JSON body (400).
|
||||||
|
|
||||||
|
A missing body (``None``) is tolerated and becomes ``{}`` downstream.
|
||||||
|
"""
|
||||||
|
if json is not None and not isinstance(json, dict):
|
||||||
|
raise ValueError("Request body must be a JSON object")
|
||||||
|
|
||||||
|
|
||||||
|
def void_transform(_data: Any, _view_args: dict[str, Any], _sent: Any) -> None:
|
||||||
|
"""Transform: discard the daemon result and return ``data: null``.
|
||||||
|
|
||||||
|
Matches routes that historically responded ``_ok(None)`` (the daemon
|
||||||
|
result was intentionally ignored by the caller).
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|||||||
+130
-263
@@ -3,11 +3,16 @@
|
|||||||
Exposed at /api/dhcp/* and delegates all operations to vacuum-walld.
|
Exposed at /api/dhcp/* and delegates all operations to vacuum-walld.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||||
|
delete,
|
||||||
|
get,
|
||||||
|
patch,
|
||||||
|
post,
|
||||||
|
)
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||||
@@ -23,252 +28,139 @@ from daemon.iface import (
|
|||||||
POST_DNSMASQ_RANGES_ADD,
|
POST_DNSMASQ_RANGES_ADD,
|
||||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("dhcp", __name__)
|
bp = Blueprint("dhcp", __name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config
|
# Config / status
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["GET"])
|
@daemon_route(GET_DNSMASQ_CONFIG, bp)
|
||||||
def get_config_bp():
|
def get_config_bp():
|
||||||
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration.
|
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the config or an error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_DNSMASQ_CONFIG))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to read DHCP config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["POST"])
|
@daemon_route(
|
||||||
|
POST_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||||
|
)
|
||||||
def post_config():
|
def post_config():
|
||||||
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration.
|
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration."""
|
||||||
|
|
||||||
Args:
|
|
||||||
request: JSON body containing the complete config object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with success status or an error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
post(POST_DNSMASQ_CONFIG, body)
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("DHCP config save rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to save DHCP config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["PATCH"])
|
@daemon_route(
|
||||||
|
PATCH_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||||
|
)
|
||||||
def patch_config():
|
def patch_config():
|
||||||
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration.
|
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration."""
|
||||||
|
|
||||||
Args:
|
|
||||||
request: JSON body containing the fields to update.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with success status or an error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
patch(PATCH_DNSMASQ_CONFIG, body)
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("DHCP config patch rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to patch DHCP config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/apply", methods=["POST"])
|
@daemon_route(POST_DNSMASQ_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||||
def apply_bp():
|
def apply_bp():
|
||||||
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service."""
|
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration."""
|
||||||
|
|
||||||
try:
|
|
||||||
post(POST_DNSMASQ_APPLY)
|
|
||||||
logger.info("dnsmasq config applied via API")
|
|
||||||
return _ok(None)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to apply dnsmasq config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
@daemon_route(GET_DNSMASQ_STATUS, bp)
|
||||||
# Status
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/status", methods=["GET"])
|
|
||||||
def status_bp():
|
def status_bp():
|
||||||
"""GET /api/dhcp/status — Retrieve dnsmasq service status."""
|
"""GET /api/dhcp/status — Retrieve dnsmasq service status."""
|
||||||
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_DNSMASQ_STATUS))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get DHCP status: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DHCP ranges
|
# DHCP ranges
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/ranges", methods=["POST"])
|
def _add_range_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def add_range_bp():
|
|
||||||
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: JSON body with `interface`, `start`, `end`, and optional `lease_time`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with success status or an error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
iface = body.get("interface", "").strip() or None
|
iface = (body.get("interface") or "").strip() or None
|
||||||
start = body.get("start", "").strip()
|
start = (body.get("start") or "").strip()
|
||||||
end = body.get("end", "").strip()
|
end = (body.get("end") or "").strip()
|
||||||
lease_time = body.get("lease_time", "12h")
|
|
||||||
if not start or not end:
|
if not start or not end:
|
||||||
return _error("'start' and 'end' are required", 400)
|
raise ValueError("'start' and 'end' are required")
|
||||||
try:
|
return {
|
||||||
post(
|
|
||||||
POST_DNSMASQ_RANGES_ADD,
|
|
||||||
{
|
|
||||||
"interface": iface or "",
|
"interface": iface or "",
|
||||||
"start": start,
|
"start": start,
|
||||||
"end": end,
|
"end": end,
|
||||||
"lease_time": lease_time,
|
"lease_time": body.get("lease_time", "12h"),
|
||||||
},
|
}
|
||||||
)
|
|
||||||
logger.info("DHCP range added via API: %s-%s", start, end)
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Add DHCP range rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add DHCP range: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/ranges", methods=["DELETE"])
|
def _remove_range_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def remove_range_bp():
|
|
||||||
"""DELETE /api/dhcp/ranges — Remove a DHCP address range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: JSON body with `interface`, `start`, and `end`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with success status or an error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
iface = body.get("interface", "").strip() or ""
|
iface = (body.get("interface") or "").strip() or ""
|
||||||
start = body.get("start", "").strip()
|
start = (body.get("start") or "").strip()
|
||||||
end = body.get("end", "").strip()
|
end = (body.get("end") or "").strip()
|
||||||
if not start or not end:
|
if not start or not end:
|
||||||
return _error("'start' and 'end' are required", 400)
|
raise ValueError("'start' and 'end' are required")
|
||||||
try:
|
return {"interface": iface, "start": start, "end": end}
|
||||||
delete(
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_DNSMASQ_RANGES_ADD,
|
||||||
|
bp,
|
||||||
|
rule="/ranges",
|
||||||
|
body=_add_range_body,
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
|
def add_range_bp():
|
||||||
|
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||||
{"interface": iface, "start": start, "end": end},
|
bp,
|
||||||
)
|
rule="/ranges",
|
||||||
logger.info("DHCP range removed via API: %s-%s", start, end)
|
body=_remove_range_body,
|
||||||
return _ok(None)
|
transform=void_transform,
|
||||||
except NotFound as exc:
|
)
|
||||||
logger.info("Remove DHCP range not found: %s", exc)
|
def remove_range_bp():
|
||||||
return _error(str(exc), 404)
|
"""DELETE /api/dhcp/ranges — Remove a DHCP address range."""
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove DHCP range: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
@daemon_route(GET_DNSMASQ_LEASES, bp)
|
||||||
# Leases
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/leases", methods=["GET"])
|
|
||||||
def leases_bp():
|
def leases_bp():
|
||||||
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table."""
|
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table."""
|
||||||
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_DNSMASQ_LEASES))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to read lease table: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Static leases
|
# Static leases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/static-lease", methods=["POST"])
|
def _add_static_lease_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def add_static_lease_bp():
|
|
||||||
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: JSON body with `mac`, `ip`, and optional `hostname`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with lease details or an error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
mac = body.get("mac", "").strip()
|
mac = (body.get("mac") or "").strip()
|
||||||
ip = body.get("ip", "").strip()
|
ip = (body.get("ip") or "").strip()
|
||||||
hostname = body.get("hostname")
|
|
||||||
if not mac or not ip:
|
if not mac or not ip:
|
||||||
return _error("'mac' and 'ip' are required", 400)
|
raise ValueError("'mac' and 'ip' are required")
|
||||||
try:
|
return {"mac": mac, "ip": ip, "hostname": body.get("hostname")}
|
||||||
post(
|
|
||||||
POST_DNSMASQ_STATIC_LEASE_ADD, {"mac": mac, "ip": ip, "hostname": hostname}
|
|
||||||
)
|
|
||||||
logger.info("Static lease added via API: %s -> %s", mac, ip)
|
|
||||||
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Add static lease rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add static lease: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/static-lease/<mac>", methods=["DELETE"])
|
def _static_lease_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
def remove_static_lease_bp(mac):
|
return {"mac": sent["mac"], "ip": sent["ip"], "hostname": sent["hostname"]}
|
||||||
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC address.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mac: MAC address of the static lease to remove.
|
|
||||||
|
|
||||||
Returns:
|
@daemon_route(
|
||||||
JSON response with success status or an error.
|
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||||
"""
|
bp,
|
||||||
try:
|
rule="/static-lease",
|
||||||
delete(DELETE_DNSMASQ_STATIC_LEASE_REMOVE, {"mac": mac})
|
body=_add_static_lease_body,
|
||||||
logger.info("Static lease removed via API: %s", mac)
|
transform=_static_lease_echo,
|
||||||
return _ok(None)
|
)
|
||||||
except NotFound as exc:
|
def add_static_lease_bp():
|
||||||
logger.info("Static lease '%s' not found: %s", mac, exc)
|
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address."""
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove static lease '%s': %s", mac, exc)
|
@daemon_route(
|
||||||
return _error(str(exc), 500)
|
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
|
||||||
|
bp,
|
||||||
|
rule="/static-lease/<mac>",
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
|
def remove_static_lease_bp():
|
||||||
|
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -276,35 +168,42 @@ def remove_static_lease_bp(mac):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/dns-record", methods=["POST"])
|
def _add_dns_record_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def add_dns_record_bp():
|
|
||||||
"""POST /api/dhcp/dns-record — Add a DNS record.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: JSON body with `name`, `address`, and optional `hostname`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with record details or an error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
name = body.get("name", "").strip()
|
name = (body.get("name") or "").strip()
|
||||||
address = body.get("address", "").strip()
|
address = (body.get("address") or "").strip()
|
||||||
hostname = body.get("hostname")
|
|
||||||
if not name or not address:
|
if not name or not address:
|
||||||
return _error("'name' and 'address' are required", 400)
|
raise ValueError("'name' and 'address' are required")
|
||||||
try:
|
return {"name": name, "address": address, "hostname": body.get("hostname")}
|
||||||
post(
|
|
||||||
|
|
||||||
|
def _dns_record_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {
|
||||||
|
"name": sent["name"],
|
||||||
|
"address": sent["address"],
|
||||||
|
"hostname": sent["hostname"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||||
{"name": name, "address": address, "hostname": hostname},
|
bp,
|
||||||
)
|
rule="/dns-record",
|
||||||
logger.info("DNS record added via API: %s -> %s", name, address)
|
body=_add_dns_record_body,
|
||||||
return _ok({"name": name, "address": address, "hostname": hostname})
|
transform=_dns_record_echo,
|
||||||
except BadRequest as exc:
|
)
|
||||||
logger.info("Add DNS record rejected: %s", exc)
|
def add_dns_record_bp():
|
||||||
return _error(str(exc), 400)
|
"""POST /api/dhcp/dns-record — Add a DNS record."""
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add DNS record: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
@daemon_route(
|
||||||
|
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||||
|
bp,
|
||||||
|
rule="/dns-record/<name>",
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
|
def remove_dns_record_bp():
|
||||||
|
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -312,48 +211,16 @@ def add_dns_record_bp():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/domain", methods=["POST"])
|
def _set_domain_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
|
return {"domain": (request.get_json(silent=True) or {}).get("domain")}
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_DNSMASQ_DOMAIN,
|
||||||
|
bp,
|
||||||
|
precheck=require_dict_body,
|
||||||
|
body=_set_domain_body,
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
def set_domain_bp():
|
def set_domain_bp():
|
||||||
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
|
"""POST /api/dhcp/domain — Set or clear the DNS search domain."""
|
||||||
|
|
||||||
Args:
|
|
||||||
request: JSON body with `domain` field (string or null to clear).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with success status or an error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
|
|
||||||
logger.info("DNS domain updated via API: %s", body.get("domain"))
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Set DNS domain rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to set DNS domain: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/dns-record/<name>", methods=["DELETE"])
|
|
||||||
def remove_dns_record_bp(name):
|
|
||||||
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Name of the DNS record to remove.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with success status or an error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
delete(DELETE_DNSMASQ_DNS_RECORD_REMOVE, {"name": name})
|
|
||||||
logger.info("DNS record removed via API: %s", name)
|
|
||||||
return _ok(None)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("DNS record '%s' not found: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove DNS record '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
+248
-523
@@ -4,10 +4,16 @@ Exposed at /api/firewall/* and delegates all operations to vacuum-walld.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||||
|
delete,
|
||||||
|
get,
|
||||||
|
patch,
|
||||||
|
post,
|
||||||
|
)
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||||
@@ -30,169 +36,179 @@ from daemon.iface import (
|
|||||||
POST_FIREWALL_ZONES_INTERFACES,
|
POST_FIREWALL_ZONES_INTERFACES,
|
||||||
POST_FIREWALL_ZONES_SERVICES,
|
POST_FIREWALL_ZONES_SERVICES,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
bp = Blueprint("firewall", __name__)
|
bp = Blueprint("firewall", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Body builders / prechecks / transforms
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _config_save_precheck(json: Any, _va: Any) -> None:
|
||||||
|
body = json or {}
|
||||||
|
if "zones" not in body:
|
||||||
|
raise ValueError("'zones' key is required")
|
||||||
|
if not isinstance(body["zones"], dict):
|
||||||
|
raise ValueError("'zones' must be a dict")
|
||||||
|
|
||||||
|
|
||||||
|
def _interfaces_precheck(json: Any, _va: Any) -> None:
|
||||||
|
if not isinstance((json or {}).get("interfaces", []), list):
|
||||||
|
raise ValueError("'interfaces' must be a list")
|
||||||
|
|
||||||
|
|
||||||
|
def _services_precheck(json: Any, _va: Any) -> None:
|
||||||
|
if not isinstance((json or {}).get("services", []), list):
|
||||||
|
raise ValueError("'services' must be a list")
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_data() -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
||||||
|
return {
|
||||||
|
"pending": pending.get("pending", []),
|
||||||
|
"needs_apply": pending.get("needs_apply", False),
|
||||||
|
"unmanaged_zones": pending.get("unmanaged_zones", {}),
|
||||||
|
}
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# The save already succeeded; the follow-up read is best-effort so a
|
||||||
|
# failure degrades to a bare ``config_saved`` rather than a 500.
|
||||||
|
logger.warning("Failed to read pending state after config save: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _config_saved(_data: Any, _va: Any, _sent: Any) -> Any:
|
||||||
|
return {"config_saved": True, **(_pending_data() or {})}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_zone_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
zone_name = (body.get("name") or "").strip()
|
||||||
|
if not zone_name:
|
||||||
|
raise ValueError("Zone name is required")
|
||||||
|
target = (body.get("target") or "").strip() or "default"
|
||||||
|
return {"name": zone_name, "target": target}
|
||||||
|
|
||||||
|
|
||||||
|
def _zones_list(data: Any, _va: Any, _sent: Any) -> Any:
|
||||||
|
return {"active": data.get("active", {}), "available": data.get("available", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_interfaces_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"zone": sent["zone"], "interfaces": sent.get("interfaces", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_services_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"zone": sent["zone"], "services": sent.get("services", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_rich_rule_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
zone = (body.get("zone") or "").strip()
|
||||||
|
rule = (body.get("rule") or "").strip()
|
||||||
|
if not zone or not rule:
|
||||||
|
raise ValueError("Both 'zone' and 'rule' are required")
|
||||||
|
return {"zone": zone, "rule": rule}
|
||||||
|
|
||||||
|
|
||||||
|
def _rich_rule_add_echo(data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"zone": sent["zone"], "id": data["id"], "rule": sent["rule"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _rich_rule_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"zone": sent["zone"], "id": sent["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _masquerade_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
zone = (body.get("zone") or "").strip()
|
||||||
|
enable = body.get("enable")
|
||||||
|
if not zone or enable is None:
|
||||||
|
raise ValueError("'zone' and 'enable' (bool) are required")
|
||||||
|
return {"zone": zone, "enable": bool(enable)}
|
||||||
|
|
||||||
|
|
||||||
|
def _masquerade_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"zone": sent["zone"], "masquerade": sent["enable"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_forward_port_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
zone = (body.get("zone") or "").strip()
|
||||||
|
port = body.get("port")
|
||||||
|
proto = (body.get("proto") or "").strip()
|
||||||
|
toaddr = body.get("toaddr")
|
||||||
|
toport = body.get("toport")
|
||||||
|
if not zone or port is None or not proto:
|
||||||
|
raise ValueError("'zone', 'port', and 'proto' are required")
|
||||||
|
try:
|
||||||
|
port_int = int(port)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("'port' must be an integer") from None
|
||||||
|
toport_int = None
|
||||||
|
if toport is not None:
|
||||||
|
try:
|
||||||
|
toport_int = int(toport)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("'toport' must be an integer") from None
|
||||||
|
return {
|
||||||
|
"zone": zone,
|
||||||
|
"port": port_int,
|
||||||
|
"proto": proto,
|
||||||
|
"toaddr": str(toaddr) if toaddr else None,
|
||||||
|
"toport": toport_int,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _forward_port_add_echo(data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {
|
||||||
|
"zone": sent["zone"],
|
||||||
|
"id": data["id"],
|
||||||
|
"port": sent["port"],
|
||||||
|
"proto": sent["proto"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _forward_port_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"zone": sent["zone"], "port": sent["port"], "proto": sent["proto"]}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Declarative config (two-step: save -> apply)
|
# Declarative config (two-step: save -> apply)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["GET"])
|
@daemon_route(GET_FIREWALL_CONFIG, bp)
|
||||||
def config_list():
|
def config_list():
|
||||||
"""Retrieve the current firewall declarative configuration.
|
"""GET /api/firewall/config — Retrieve the current firewall config."""
|
||||||
|
|
||||||
Returns JSON containing the full firewall config from the daemon.
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/firewall/config
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the config data or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_FIREWALL_CONFIG))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to read firewall config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["POST"])
|
@daemon_route(
|
||||||
|
POST_FIREWALL_CONFIG, bp, precheck=_config_save_precheck, transform=_config_saved
|
||||||
|
)
|
||||||
def config_save():
|
def config_save():
|
||||||
"""Save a new firewall declarative configuration.
|
"""POST /api/firewall/config — Save a new firewall declarative configuration."""
|
||||||
|
|
||||||
Validates that the request body contains a ``zones`` dict, forwards
|
|
||||||
to the daemon, and returns the pending state including unmanaged zones.
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/firewall/config
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON with ``zones`` dict mapping zone names to zone configs.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with ``config_saved`` flag and pending apply information.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if "zones" not in body:
|
|
||||||
return _error("'zones' key is required", 400)
|
|
||||||
if not isinstance(body["zones"], dict):
|
|
||||||
return _error("'zones' must be a dict", 400)
|
|
||||||
try:
|
|
||||||
post(POST_FIREWALL_CONFIG, body)
|
|
||||||
try:
|
|
||||||
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
|
||||||
pending_data = {
|
|
||||||
"pending": pending.get("pending", []),
|
|
||||||
"needs_apply": pending.get("needs_apply", False),
|
|
||||||
"unmanaged_zones": pending.get("unmanaged_zones", {}),
|
|
||||||
}
|
|
||||||
except RuntimeError as exc:
|
|
||||||
pending_data = None
|
|
||||||
logger.warning("Failed to read pending state after config save: %s", exc)
|
|
||||||
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
|
||||||
return _ok(
|
|
||||||
{
|
|
||||||
"config_saved": True,
|
|
||||||
**(pending_data or {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Firewall config save rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to save firewall config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["PATCH"])
|
@daemon_route(
|
||||||
|
PATCH_FIREWALL_CONFIG, bp, precheck=require_dict_body, transform=_config_saved
|
||||||
|
)
|
||||||
def patch_config():
|
def patch_config():
|
||||||
"""Partially update the firewall declarative configuration.
|
"""PATCH /api/firewall/config — Partially update the firewall configuration."""
|
||||||
|
|
||||||
Accepts a JSON body and forwards it as a patch to the daemon config
|
|
||||||
endpoint, returning the updated pending state.
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
PATCH /api/firewall/config
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON object with configuration fields to patch.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with ``config_saved`` flag and pending apply information.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
patch(PATCH_FIREWALL_CONFIG, body)
|
|
||||||
try:
|
|
||||||
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
|
||||||
pending_data = {
|
|
||||||
"pending": pending.get("pending", []),
|
|
||||||
"needs_apply": pending.get("needs_apply", False),
|
|
||||||
"unmanaged_zones": pending.get("unmanaged_zones", {}),
|
|
||||||
}
|
|
||||||
except RuntimeError as exc:
|
|
||||||
pending_data = None
|
|
||||||
logger.warning("Failed to read pending state after config patch: %s", exc)
|
|
||||||
return _ok(
|
|
||||||
{
|
|
||||||
"config_saved": True,
|
|
||||||
**(pending_data or {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Firewall config patch rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to patch firewall config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config/apply", methods=["POST"])
|
@daemon_route(POST_FIREWALL_CONFIG_APPLY, bp, body=NO_BODY)
|
||||||
def config_apply_bp():
|
def config_apply_bp():
|
||||||
"""Apply any pending firewall configuration changes.
|
"""POST /api/firewall/config/apply — Apply pending firewall config changes."""
|
||||||
|
|
||||||
Triggers the daemon to apply saved declarative config to the live
|
|
||||||
firewalld instance.
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/firewall/config/apply
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with ``applied_zones`` list or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = post(POST_FIREWALL_CONFIG_APPLY)
|
|
||||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
|
||||||
return _ok(result)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to apply firewall config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config/pending", methods=["GET"])
|
@daemon_route(GET_FIREWALL_CONFIG_PENDING, bp)
|
||||||
def config_pending_bp():
|
def config_pending_bp():
|
||||||
"""Check the pending firewall configuration state.
|
"""GET /api/firewall/config/pending — Check the pending firewall config state."""
|
||||||
|
|
||||||
Returns information about unsaved changes, whether an apply is
|
|
||||||
needed, and any unmanaged zones detected on the system.
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/firewall/config/pending
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with pending changes and apply status.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_FIREWALL_CONFIG_PENDING))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to check pending config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -200,21 +216,9 @@ def config_pending_bp():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/state", methods=["GET"])
|
@daemon_route(GET_FIREWALL_STATE, bp)
|
||||||
def get_state():
|
def get_state():
|
||||||
"""Retrieve current firewall state from the state store.
|
"""GET /api/firewall/state — Retrieve current firewall state."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/firewall/state
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with firewall state data or an error message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_FIREWALL_STATE))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get firewall state: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -222,182 +226,67 @@ def get_state():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/zones", methods=["GET"])
|
@daemon_route(GET_FIREWALL_ZONES, bp, transform=_zones_list)
|
||||||
def list_zones():
|
def list_zones():
|
||||||
"""List all active and available firewall zones.
|
"""GET /api/firewall/zones — List active and available firewall zones."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/firewall/zones
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with ``active`` zones dict and ``available`` zones list.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = get(GET_FIREWALL_ZONES)
|
|
||||||
return _ok(
|
|
||||||
{"active": data.get("active", {}), "available": data.get("available", [])}
|
|
||||||
)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list zones: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/zones/<name>", methods=["GET"])
|
@daemon_route(
|
||||||
def zone_details(name: str):
|
GET_FIREWALL_ZONES_INFO, bp, rule="/zones/<name>", params={"zone": "name"}
|
||||||
"""Retrieve details for a specific firewall zone.
|
)
|
||||||
|
def zone_details():
|
||||||
Endpoint:
|
"""GET /api/firewall/zones/<name> — Retrieve details for a specific zone."""
|
||||||
GET /api/firewall/zones/<name>
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Name of the zone to look up.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with zone configuration details or 404 error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
info = get(GET_FIREWALL_ZONES_INFO, {"zone": name})
|
|
||||||
return _ok(info)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Zone '%s' not found: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get zone '%s' info: %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/zones", methods=["POST"])
|
@daemon_route(
|
||||||
|
POST_FIREWALL_ZONES_CREATE,
|
||||||
|
bp,
|
||||||
|
rule="/zones",
|
||||||
|
body=_create_zone_body,
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
def create_zone_bp():
|
def create_zone_bp():
|
||||||
"""Create a new firewall zone.
|
"""POST /api/firewall/zones — Create a new firewall zone."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/firewall/zones
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON with ``name`` (required) and optional ``target`` string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation or error if the zone already exists.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
zone_name = body.get("name", "").strip()
|
|
||||||
target = body.get("target", "default").strip() or "default"
|
|
||||||
if not zone_name:
|
|
||||||
return _error("Zone name is required", 400)
|
|
||||||
try:
|
|
||||||
post(POST_FIREWALL_ZONES_CREATE, {"name": zone_name, "target": target})
|
|
||||||
logger.info("Zone '%s' created via API", zone_name)
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Zone '%s' creation rejected: %s", zone_name, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to create zone '%s': %s", zone_name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/zones/<name>", methods=["DELETE"])
|
@daemon_route(
|
||||||
def delete_zone_bp(name: str):
|
DELETE_FIREWALL_ZONES_DELETE,
|
||||||
"""Delete a firewall zone by name.
|
bp,
|
||||||
|
rule="/zones/<name>",
|
||||||
Endpoint:
|
params={"zone": "name"},
|
||||||
DELETE /api/firewall/zones/<name>
|
transform=void_transform,
|
||||||
|
)
|
||||||
Args:
|
def delete_zone_bp():
|
||||||
name: Name of the zone to delete.
|
"""DELETE /api/firewall/zones/<name> — Delete a firewall zone by name."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation or 404 if the zone does not exist.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
delete(DELETE_FIREWALL_ZONES_DELETE, {"zone": name})
|
|
||||||
logger.info("Zone '%s' deleted via API", name)
|
|
||||||
return _ok(None)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Zone '%s' not found: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to delete zone '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Zone interfaces
|
# Zone interfaces / services
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/zones/<name>/interfaces", methods=["POST"])
|
@daemon_route(
|
||||||
def set_zone_interfaces_bp(name: str):
|
POST_FIREWALL_ZONES_INTERFACES,
|
||||||
"""Set the network interfaces assigned to a firewall zone.
|
bp,
|
||||||
|
rule="/zones/<name>/interfaces",
|
||||||
Replaces all existing interfaces for the zone with the provided list.
|
params={"zone": "name"},
|
||||||
|
precheck=_interfaces_precheck,
|
||||||
Endpoint:
|
transform=_zone_interfaces_echo,
|
||||||
POST /api/firewall/zones/<name>/interfaces
|
)
|
||||||
|
def set_zone_interfaces_bp():
|
||||||
Args:
|
"""POST /api/firewall/zones/<name>/interfaces — Set a zone's interfaces."""
|
||||||
name: Zone name.
|
|
||||||
body: JSON with ``interfaces`` list of interface names.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation with zone and updated interfaces list.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
interfaces = body.get("interfaces", [])
|
|
||||||
if not isinstance(interfaces, list):
|
|
||||||
return _error("'interfaces' must be a list", 400)
|
|
||||||
try:
|
|
||||||
post(POST_FIREWALL_ZONES_INTERFACES, {"zone": name, "interfaces": interfaces})
|
|
||||||
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
|
|
||||||
return _ok({"zone": name, "interfaces": interfaces})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Set interfaces for zone '%s' rejected: %s", name, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Zone '%s' not found: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
@daemon_route(
|
||||||
# Zone services
|
POST_FIREWALL_ZONES_SERVICES,
|
||||||
# ---------------------------------------------------------------------------
|
bp,
|
||||||
|
rule="/zones/<name>/services",
|
||||||
|
params={"zone": "name"},
|
||||||
@bp.route("/zones/<name>/services", methods=["POST"])
|
precheck=_services_precheck,
|
||||||
def set_zone_services_bp(name: str):
|
transform=_zone_services_echo,
|
||||||
"""Set the allowed services for a firewall zone.
|
)
|
||||||
|
def set_zone_services_bp():
|
||||||
Replaces all existing services for the zone with the provided list.
|
"""POST /api/firewall/zones/<name>/services — Set a zone's allowed services."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/firewall/zones/<name>/services
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Zone name.
|
|
||||||
body: JSON with ``services`` list of service names.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation with zone and updated services list.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
services = body.get("services", [])
|
|
||||||
if not isinstance(services, list):
|
|
||||||
return _error("'services' must be a list", 400)
|
|
||||||
try:
|
|
||||||
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
|
|
||||||
return _ok({"zone": name, "services": services})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Set services for zone '%s' rejected: %s", name, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Zone '%s' not found: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to set services for zone '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -405,38 +294,14 @@ def set_zone_services_bp(name: str):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/services", methods=["GET"])
|
@daemon_route(GET_FIREWALL_SERVICES, bp)
|
||||||
def list_services():
|
def list_services():
|
||||||
"""List all available firewall services.
|
"""GET /api/firewall/services — List all available firewall services."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/firewall/services
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with the list of available service names.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_FIREWALL_SERVICES))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list services: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/interfaces", methods=["GET"])
|
@daemon_route(GET_FIREWALL_INTERFACES, bp)
|
||||||
def list_interfaces():
|
def list_interfaces():
|
||||||
"""List all available network interfaces.
|
"""GET /api/firewall/interfaces — List all available network interfaces."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/firewall/interfaces
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with the list of available interface names.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_FIREWALL_INTERFACES))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list interfaces: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -444,80 +309,31 @@ def list_interfaces():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/rich-rules", methods=["POST"])
|
@daemon_route(
|
||||||
|
POST_FIREWALL_RICH_RULES_ADD,
|
||||||
|
bp,
|
||||||
|
rule="/rich-rules",
|
||||||
|
body=_add_rich_rule_body,
|
||||||
|
transform=_rich_rule_add_echo,
|
||||||
|
)
|
||||||
def add_rich_rule_bp():
|
def add_rich_rule_bp():
|
||||||
"""Add a rich rule to a firewall zone.
|
"""POST /api/firewall/rich-rules — Add a rich rule to a firewall zone."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/firewall/rich-rules
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON with ``zone`` (zone name) and ``rule`` (XML rule string).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with zone, generated rule ID, and rule string.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
zone = body.get("zone", "").strip()
|
|
||||||
rule = body.get("rule", "").strip()
|
|
||||||
if not zone or not rule:
|
|
||||||
return _error("Both 'zone' and 'rule' are required", 400)
|
|
||||||
try:
|
|
||||||
entry = post(POST_FIREWALL_RICH_RULES_ADD, {"zone": zone, "rule": rule})
|
|
||||||
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
|
|
||||||
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Add rich rule for zone '%s' rejected: %s", zone, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/rich-rules/<zone>", methods=["GET"])
|
@daemon_route(GET_FIREWALL_RICH_RULES, bp, rule="/rich-rules/<zone>")
|
||||||
def list_rich_rules(zone: str):
|
def list_rich_rules():
|
||||||
"""List rich rules for a specific firewall zone.
|
"""GET /api/firewall/rich-rules/<zone> — List rich rules for a zone."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/firewall/rich-rules/<zone>
|
|
||||||
|
|
||||||
Args:
|
|
||||||
zone: Zone name to list rules for.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with list of rich rule entries for the zone.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_FIREWALL_RICH_RULES, {"zone": zone}))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
|
@daemon_route(
|
||||||
def remove_rich_rule_bp(zone: str, rule_id: str):
|
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||||
"""Remove a rich rule from a firewall zone by ID.
|
bp,
|
||||||
|
rule="/rich-rules/<zone>/<rule_id>",
|
||||||
Endpoint:
|
params={"id": "rule_id"},
|
||||||
DELETE /api/firewall/rich-rules/<zone>/<rule_id>
|
transform=_rich_rule_remove_echo,
|
||||||
|
)
|
||||||
Args:
|
def remove_rich_rule_bp():
|
||||||
zone: Zone name.
|
"""DELETE /api/firewall/rich-rules/<zone>/<rule_id> — Remove a rich rule by ID."""
|
||||||
rule_id: Rule identifier.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation or 404 if the rule does not exist.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
delete(DELETE_FIREWALL_RICH_RULES_REMOVE, {"zone": zone, "id": rule_id})
|
|
||||||
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
|
|
||||||
return _ok({"zone": zone, "id": rule_id})
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Rich rule '%s' not found in zone '%s': %s", rule_id, zone, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -525,38 +341,11 @@ def remove_rich_rule_bp(zone: str, rule_id: str):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/masquerade", methods=["POST"])
|
@daemon_route(
|
||||||
|
POST_FIREWALL_MASQUERADE, bp, body=_masquerade_body, transform=_masquerade_echo
|
||||||
|
)
|
||||||
def set_masquerade_bp():
|
def set_masquerade_bp():
|
||||||
"""Enable or disable masquerade (NAT) on a firewall zone.
|
"""POST /api/firewall/masquerade — Enable or disable masquerade (NAT)."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/firewall/masquerade
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON with ``zone`` (zone name) and ``enable`` (boolean).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation with zone and masquerade status.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
zone = body.get("zone", "").strip()
|
|
||||||
enable = body.get("enable")
|
|
||||||
if not zone or enable is None:
|
|
||||||
return _error("'zone' and 'enable' (bool) are required", 400)
|
|
||||||
try:
|
|
||||||
post(POST_FIREWALL_MASQUERADE, {"zone": zone, "enable": bool(enable)})
|
|
||||||
logger.info(
|
|
||||||
"Masquerade %s on zone '%s' via API",
|
|
||||||
"enabled" if enable else "disabled",
|
|
||||||
zone,
|
|
||||||
)
|
|
||||||
return _ok({"zone": zone, "masquerade": bool(enable)})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Set masquerade for zone '%s' rejected: %s", zone, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -564,86 +353,22 @@ def set_masquerade_bp():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/forward-port", methods=["POST"])
|
@daemon_route(
|
||||||
def add_forward_port_bp():
|
|
||||||
"""Add a port forwarding rule to a firewall zone.
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/firewall/forward-port
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON with ``zone`` (zone name), ``port`` (int), ``proto``
|
|
||||||
(tcp/udp), optional ``toaddr`` and ``toport``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation with zone, generated ID, port, and protocol.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
zone = body.get("zone", "").strip()
|
|
||||||
port = body.get("port")
|
|
||||||
proto = body.get("proto", "").strip()
|
|
||||||
toaddr = body.get("toaddr")
|
|
||||||
toport = body.get("toport")
|
|
||||||
if not zone or port is None or not proto:
|
|
||||||
return _error("'zone', 'port', and 'proto' are required", 400)
|
|
||||||
try:
|
|
||||||
port_int = int(port)
|
|
||||||
except ValueError:
|
|
||||||
return _error("'port' must be an integer", 400)
|
|
||||||
toport_int = None
|
|
||||||
if toport is not None:
|
|
||||||
try:
|
|
||||||
toport_int = int(toport)
|
|
||||||
except ValueError:
|
|
||||||
return _error("'toport' must be an integer", 400)
|
|
||||||
toaddr_str = str(toaddr) if toaddr else None
|
|
||||||
try:
|
|
||||||
entry = post(
|
|
||||||
POST_FIREWALL_FORWARD_PORT_ADD,
|
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||||
{
|
bp,
|
||||||
"zone": zone,
|
rule="/forward-port",
|
||||||
"port": port_int,
|
body=_add_forward_port_body,
|
||||||
"proto": proto,
|
transform=_forward_port_add_echo,
|
||||||
"toaddr": toaddr_str,
|
)
|
||||||
"toport": toport_int,
|
def add_forward_port_bp():
|
||||||
},
|
"""POST /api/firewall/forward-port — Add a port forwarding rule to a zone."""
|
||||||
)
|
|
||||||
return _ok({"zone": zone, "id": entry["id"], "port": port_int, "proto": proto})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Add forward port rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add forward port: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
|
@daemon_route(
|
||||||
def remove_forward_port_bp(zone: str, port: int, proto: str):
|
|
||||||
"""Remove a port forwarding rule from a firewall zone.
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
|
|
||||||
|
|
||||||
Args:
|
|
||||||
zone: Zone name.
|
|
||||||
port: Port number.
|
|
||||||
proto: Protocol (tcp/udp).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation or 404 if the rule does not exist.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
delete(
|
|
||||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||||
{"zone": zone, "port": port, "proto": proto},
|
bp,
|
||||||
)
|
rule="/forward-port/<zone>/<int:port>/<proto>",
|
||||||
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
|
transform=_forward_port_remove_echo,
|
||||||
return _ok({"zone": zone, "port": port, "proto": proto})
|
)
|
||||||
except NotFound as exc:
|
def remove_forward_port_bp():
|
||||||
logger.info(
|
"""DELETE /api/firewall/forward-port/<zone>/<port>/<proto> — Remove a rule."""
|
||||||
"Forward port %s/%s not found in zone '%s': %s", port, proto, zone, exc
|
|
||||||
)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove forward port from zone '%s': %s", zone, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
+7
-36
@@ -3,11 +3,9 @@
|
|||||||
Wraps raw log text in the standard JSON response contract.
|
Wraps raw log text in the standard JSON response contract.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import NotFound, get
|
from daemon.client import get # noqa: F401 (resolved via module globals at dispatch)
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
GET_LOGS_APP,
|
GET_LOGS_APP,
|
||||||
GET_LOGS_DNSMASQ,
|
GET_LOGS_DNSMASQ,
|
||||||
@@ -15,58 +13,31 @@ from daemon.iface import (
|
|||||||
GET_LOGS_NGINX_ACCESS,
|
GET_LOGS_NGINX_ACCESS,
|
||||||
GET_LOGS_NGINX_ERROR,
|
GET_LOGS_NGINX_ERROR,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import daemon_route
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("logs", __name__)
|
bp = Blueprint("logs", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/journal")
|
@daemon_route(GET_LOGS_JOURNAL, bp)
|
||||||
def journal():
|
def journal():
|
||||||
"""GET /api/logs/journal — Return systemd journal log lines."""
|
"""GET /api/logs/journal — Return systemd journal log lines."""
|
||||||
try:
|
|
||||||
return _ok(get(GET_LOGS_JOURNAL))
|
|
||||||
except RuntimeError:
|
|
||||||
return _error("error reading journal", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/nginx/access")
|
@daemon_route(GET_LOGS_NGINX_ACCESS, bp)
|
||||||
def nginx_access():
|
def nginx_access():
|
||||||
"""GET /api/logs/nginx/access — Return nginx access log lines."""
|
"""GET /api/logs/nginx/access — Return nginx access log lines."""
|
||||||
try:
|
|
||||||
return _ok(get(GET_LOGS_NGINX_ACCESS))
|
|
||||||
except NotFound:
|
|
||||||
return _error("log file not found", 404)
|
|
||||||
except RuntimeError:
|
|
||||||
return _error("error reading log", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/nginx/error")
|
@daemon_route(GET_LOGS_NGINX_ERROR, bp)
|
||||||
def nginx_error():
|
def nginx_error():
|
||||||
"""GET /api/logs/nginx/error — Return nginx error log lines."""
|
"""GET /api/logs/nginx/error — Return nginx error log lines."""
|
||||||
try:
|
|
||||||
return _ok(get(GET_LOGS_NGINX_ERROR))
|
|
||||||
except NotFound:
|
|
||||||
return _error("log file not found", 404)
|
|
||||||
except RuntimeError:
|
|
||||||
return _error("error reading log", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/dnsmasq")
|
@daemon_route(GET_LOGS_DNSMASQ, bp)
|
||||||
def dnsmasq():
|
def dnsmasq():
|
||||||
"""GET /api/logs/dnsmasq — Return dnsmasq log lines."""
|
"""GET /api/logs/dnsmasq — Return dnsmasq log lines."""
|
||||||
try:
|
|
||||||
return _ok(get(GET_LOGS_DNSMASQ))
|
|
||||||
except RuntimeError:
|
|
||||||
return _error("error reading journal", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/app")
|
@daemon_route(GET_LOGS_APP, bp)
|
||||||
def app_log():
|
def app_log():
|
||||||
"""GET /api/logs/app — Return application log lines."""
|
"""GET /api/logs/app — Return application log lines."""
|
||||||
try:
|
|
||||||
return _ok(get(GET_LOGS_APP))
|
|
||||||
except NotFound:
|
|
||||||
return _error("log file not found", 404)
|
|
||||||
except RuntimeError:
|
|
||||||
return _error("error reading log", 500)
|
|
||||||
|
|||||||
+45
-130
@@ -4,11 +4,14 @@ Exposes /api/network/* and delegates to vacuum-walld for interface
|
|||||||
IP configuration via systemd-networkd.
|
IP configuration via systemd-networkd.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import NotFound, get, post
|
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
|
||||||
|
get,
|
||||||
|
post,
|
||||||
|
)
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
GET_NETWORK_INFER_DHCP_RANGES,
|
GET_NETWORK_INFER_DHCP_RANGES,
|
||||||
GET_NETWORK_INFER_ZONES,
|
GET_NETWORK_INFER_ZONES,
|
||||||
@@ -19,150 +22,62 @@ from daemon.iface import (
|
|||||||
POST_NETWORK_INTERFACE_RELOAD,
|
POST_NETWORK_INTERFACE_RELOAD,
|
||||||
)
|
)
|
||||||
from lib.common import validate_interface_name
|
from lib.common import validate_interface_name
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import daemon_route
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("network", __name__)
|
bp = Blueprint("network", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/interfaces", methods=["GET"])
|
def _check_iface(_json: Any, view_args: dict[str, Any]) -> None:
|
||||||
|
"""Validate the interface name path param (400 on a bad name)."""
|
||||||
|
validate_interface_name(view_args["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _applied(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
|
||||||
|
return {"name": view_args["name"], "applied": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _reloaded(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
|
||||||
|
return {"name": view_args["name"], "reloaded": True}
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(GET_NETWORK_INTERFACES, bp)
|
||||||
def list_interfaces():
|
def list_interfaces():
|
||||||
"""List all interfaces with their network config and runtime state.
|
"""GET /api/network/interfaces — List interfaces with config + runtime state."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/network/interfaces
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with interface config + runtime state.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_NETWORK_INTERFACES))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list network interfaces: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/interfaces/<name>", methods=["GET"])
|
@daemon_route(GET_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface)
|
||||||
def get_interface(name: str):
|
def get_interface():
|
||||||
"""Get config + runtime state for a specific interface.
|
"""GET /api/network/interfaces/<name> — Config + runtime state for one interface."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/network/interfaces/<name>
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with interface config and runtime state.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
validate_interface_name(name)
|
|
||||||
return _ok(get(GET_NETWORK_INTERFACE_NAME, {"name": name}))
|
|
||||||
except ValueError as exc:
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Interface '%s' not found: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get interface '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/interfaces/<name>", methods=["POST"])
|
@daemon_route(
|
||||||
def save_interface(name: str):
|
POST_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface, transform=_applied
|
||||||
"""Save and apply network config for an interface.
|
)
|
||||||
|
def save_interface():
|
||||||
Endpoint:
|
"""POST /api/network/interfaces/<name> — Save and apply an interface's config."""
|
||||||
POST /api/network/interfaces/<name>
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON with addresses, gateway, dns, routes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation.
|
|
||||||
"""
|
|
||||||
body = {**(request.get_json(silent=True) or {}), "name": name}
|
|
||||||
try:
|
|
||||||
validate_interface_name(name)
|
|
||||||
post(POST_NETWORK_INTERFACE_NAME, body)
|
|
||||||
logger.info("Interface '%s' config saved", name)
|
|
||||||
return _ok({"name": name, "applied": True})
|
|
||||||
except ValueError as exc:
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except NotFound as exc:
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to save interface '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/interfaces/<name>/reload", methods=["POST"])
|
@daemon_route(
|
||||||
def reload_interface(name: str):
|
POST_NETWORK_INTERFACE_RELOAD,
|
||||||
"""Reload networkd for a single interface.
|
bp,
|
||||||
|
precheck=_check_iface,
|
||||||
Endpoint:
|
body={},
|
||||||
POST /api/network/interfaces/<name>/reload
|
transform=_reloaded,
|
||||||
|
)
|
||||||
Returns:
|
def reload_interface():
|
||||||
JSON confirmation.
|
"""POST /api/network/interfaces/<name>/reload — Reload networkd for one interface."""
|
||||||
"""
|
|
||||||
try:
|
|
||||||
validate_interface_name(name)
|
|
||||||
post(POST_NETWORK_INTERFACE_RELOAD, {"name": name})
|
|
||||||
logger.info("Interface '%s' reloaded", name)
|
|
||||||
return _ok({"name": name, "reloaded": True})
|
|
||||||
except ValueError as exc:
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to reload interface '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/apply", methods=["POST"])
|
@daemon_route(POST_NETWORK_APPLY, bp)
|
||||||
def apply_all():
|
def apply_all():
|
||||||
"""Apply network config for ALL interfaces (full sync).
|
"""POST /api/network/apply — Apply network config for ALL interfaces."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/network/apply
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with number of interfaces applied.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = post(POST_NETWORK_APPLY, {})
|
|
||||||
logger.info("Network config applied: %d interfaces", result.get("applied", 0))
|
|
||||||
return _ok(result)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to apply network config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/infer-dhcp-ranges", methods=["GET"])
|
@daemon_route(GET_NETWORK_INFER_DHCP_RANGES, bp)
|
||||||
def infer_dhcp_ranges():
|
def infer_dhcp_ranges():
|
||||||
"""Suggest candidate DHCP ranges based on static interface IPs.
|
"""GET /api/network/infer-dhcp-ranges — Suggest candidate DHCP ranges."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/network/infer-dhcp-ranges
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with per-interface suggested DHCP ranges.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_NETWORK_INFER_DHCP_RANGES))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to infer DHCP ranges: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/infer-zones", methods=["GET"])
|
@daemon_route(GET_NETWORK_INFER_ZONES, bp)
|
||||||
def infer_zones():
|
def infer_zones():
|
||||||
"""Suggest firewalld zone assignments for configured interfaces.
|
"""GET /api/network/infer-zones — Suggest firewalld zone assignments."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/network/infer-zones
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with per-interface suggested zone names.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_NETWORK_INFER_ZONES))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to infer zones: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
+112
-296
@@ -3,11 +3,17 @@
|
|||||||
Exposed at /api/proxy/* and delegates to vacuum-walld.
|
Exposed at /api/proxy/* and delegates to vacuum-walld.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
|
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||||
|
BadRequest,
|
||||||
|
delete,
|
||||||
|
get,
|
||||||
|
patch,
|
||||||
|
post,
|
||||||
|
)
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_NGINX_BACKENDS_REMOVE,
|
DELETE_NGINX_BACKENDS_REMOVE,
|
||||||
DELETE_NGINX_DOMAINS_REMOVE,
|
DELETE_NGINX_DOMAINS_REMOVE,
|
||||||
@@ -24,144 +30,59 @@ from daemon.iface import (
|
|||||||
POST_NGINX_SSL_APPLY,
|
POST_NGINX_SSL_APPLY,
|
||||||
POST_NGINX_TEST,
|
POST_NGINX_TEST,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("proxy", __name__)
|
bp = Blueprint("proxy", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/ssl-apply", methods=["POST"])
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_NGINX_SSL_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||||
def ssl_apply_bp():
|
def ssl_apply_bp():
|
||||||
"""Apply SSL snippet config.
|
"""POST /api/proxy/ssl-apply — Apply the SSL snippet config."""
|
||||||
|
|
||||||
POST /api/proxy/ssl-apply
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"ok": true}`` on success.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If nginx SSL snippet write fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
post(POST_NGINX_SSL_APPLY)
|
|
||||||
logger.info("SSL snippet written via API")
|
|
||||||
return _ok(None)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to write SSL snippet: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["GET"])
|
@daemon_route(GET_NGINX_CONFIG, bp)
|
||||||
def get_config_bp():
|
def get_config_bp():
|
||||||
"""Get the current nginx proxy configuration.
|
"""GET /api/proxy/config — Get the current nginx proxy configuration."""
|
||||||
|
|
||||||
GET /api/proxy/config
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Current config dict from the daemon.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_NGINX_CONFIG))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to read proxy config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["POST"])
|
@daemon_route(
|
||||||
|
POST_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||||
|
)
|
||||||
def post_config():
|
def post_config():
|
||||||
"""Save the nginx proxy configuration.
|
"""POST /api/proxy/config — Save the nginx proxy configuration."""
|
||||||
|
|
||||||
POST /api/proxy/config
|
|
||||||
|
|
||||||
Body:
|
|
||||||
Any JSON object to merge into the config.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"ok": true}`` on success.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
post(POST_NGINX_CONFIG, body)
|
|
||||||
logger.info("Proxy config saved: %s", sorted(body.keys()))
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Proxy config save rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to save proxy config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["PATCH"])
|
@daemon_route(
|
||||||
|
PATCH_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||||
|
)
|
||||||
def patch_config():
|
def patch_config():
|
||||||
"""Partially update the nginx proxy configuration.
|
"""PATCH /api/proxy/config — Partially update the nginx proxy configuration."""
|
||||||
|
|
||||||
PATCH /api/proxy/config
|
|
||||||
|
|
||||||
Body:
|
|
||||||
JSON object with fields to patch.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"ok": true}`` on success.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
patch(PATCH_NGINX_CONFIG, body)
|
|
||||||
logger.info("Proxy config patched: %s", sorted(body.keys()))
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Proxy config patch rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to patch proxy config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/domains", methods=["GET"])
|
@daemon_route(GET_NGINX_DOMAINS, bp)
|
||||||
def list_domains():
|
def list_domains():
|
||||||
"""List all configured proxy domains.
|
"""GET /api/proxy/domains — List all configured proxy domains."""
|
||||||
|
|
||||||
GET /api/proxy/domains
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of domain dicts from the daemon.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_NGINX_DOMAINS))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list proxy domains: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/domains", methods=["POST"])
|
# ---------------------------------------------------------------------------
|
||||||
def add_domain_bp():
|
# Domain CRUD
|
||||||
"""Add a new proxy domain referencing a backend.
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
POST /api/proxy/domains
|
|
||||||
|
|
||||||
Body fields:
|
def _add_domain_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
domain: Domain name.
|
|
||||||
backend: Backend name to proxy through.
|
|
||||||
cert: Optional certificate type.
|
|
||||||
force_ssl: Optional SSL redirect flag (default ``true``).
|
|
||||||
auth: Optional domain-level auth override.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"domain": ...}`` on success.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
domain = body.get("domain", "").strip()
|
domain = (body.get("domain") or "").strip()
|
||||||
if not domain:
|
if not domain:
|
||||||
return _error("'domain' is required", 400)
|
raise ValueError("'domain' is required")
|
||||||
backend = body.get("backend", "").strip()
|
backend = (body.get("backend") or "").strip()
|
||||||
if not backend:
|
if not backend:
|
||||||
return _error("'backend' is required", 400)
|
raise ValueError("'backend' is required")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
payload = {
|
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"backend": backend,
|
"backend": backend,
|
||||||
"force_ssl": body.get("force_ssl", True),
|
"force_ssl": body.get("force_ssl", True),
|
||||||
@@ -170,105 +91,62 @@ def add_domain_bp():
|
|||||||
payload["cert"] = body["cert"]
|
payload["cert"] = body["cert"]
|
||||||
if body.get("auth") is not None:
|
if body.get("auth") is not None:
|
||||||
payload["auth"] = body["auth"]
|
payload["auth"] = body["auth"]
|
||||||
|
return payload
|
||||||
try:
|
|
||||||
post(POST_NGINX_DOMAINS_ADD, payload)
|
|
||||||
logger.info("Proxy domain added via API: %s", domain)
|
|
||||||
return _ok({"domain": domain})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Add proxy domain '%s' rejected: %s", domain, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/domains/<domain>", methods=["PUT"])
|
def _domain_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
def update_domain_bp(domain):
|
return {"domain": sent.get("domain")}
|
||||||
"""Update an existing proxy domain in-place.
|
|
||||||
|
|
||||||
PUT /api/proxy/domains/<domain>
|
|
||||||
|
|
||||||
Body fields:
|
|
||||||
Fields to merge into the domain config.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"domain": ...}`` on success.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not body:
|
|
||||||
return _error("Request body must be a JSON object with fields to update", 400)
|
|
||||||
try:
|
|
||||||
post(POST_NGINX_DOMAINS_UPDATE, {"domain": domain, **body})
|
|
||||||
logger.info("Proxy domain '%s' updated via API", domain)
|
|
||||||
return _ok({"domain": domain})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Update domain '%s' rejected: %s", domain, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Domain '%s' not found: %s", domain, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to update domain '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/domains/<domain>", methods=["DELETE"])
|
@daemon_route(
|
||||||
def remove_domain_bp(domain):
|
POST_NGINX_DOMAINS_ADD,
|
||||||
"""Remove a proxy domain.
|
bp,
|
||||||
|
rule="/domains",
|
||||||
DELETE /api/proxy/domains/<domain>
|
body=_add_domain_body,
|
||||||
|
transform=_domain_echo,
|
||||||
Returns:
|
)
|
||||||
``{"domain": ...}`` on success.
|
def add_domain_bp():
|
||||||
"""
|
"""POST /api/proxy/domains — Add a new proxy domain referencing a backend."""
|
||||||
try:
|
|
||||||
delete(DELETE_NGINX_DOMAINS_REMOVE, {"domain": domain})
|
|
||||||
logger.info("Proxy domain removed via API: %s", domain)
|
|
||||||
return _ok({"domain": domain})
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Domain '%s' not found: %s", domain, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove domain '%s': %s", domain, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/apply", methods=["POST"])
|
def _update_domain_precheck(json: Any, _va: Any) -> None:
|
||||||
|
if not json:
|
||||||
|
raise ValueError("Request body must be a JSON object with fields to update")
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_NGINX_DOMAINS_UPDATE,
|
||||||
|
bp,
|
||||||
|
rule="/domains/<domain>",
|
||||||
|
methods=["PUT"],
|
||||||
|
precheck=_update_domain_precheck,
|
||||||
|
transform=_domain_echo,
|
||||||
|
)
|
||||||
|
def update_domain_bp():
|
||||||
|
"""PUT /api/proxy/domains/<domain> — Update an existing proxy domain in-place."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
DELETE_NGINX_DOMAINS_REMOVE, bp, rule="/domains/<domain>", transform=_domain_echo
|
||||||
|
)
|
||||||
|
def remove_domain_bp():
|
||||||
|
"""DELETE /api/proxy/domains/<domain> — Remove a proxy domain."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_NGINX_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||||
def apply_bp():
|
def apply_bp():
|
||||||
"""Generate all nginx configs and reload nginx.
|
"""POST /api/proxy/apply — Generate all nginx configs and reload nginx."""
|
||||||
|
|
||||||
POST /api/proxy/apply
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"ok": true}`` on success.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
post(POST_NGINX_APPLY)
|
|
||||||
logger.info("nginx config applied via API")
|
|
||||||
return _ok(None)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to apply nginx config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/test", methods=["POST"])
|
def _test_transform(data: Any, _va: Any, _sent: Any) -> Any:
|
||||||
|
if data.get("valid"):
|
||||||
|
return {"valid": True, "output": data.get("output", "")}
|
||||||
|
raise BadRequest(data.get("output", "unknown error"))
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_NGINX_TEST, bp, body=NO_BODY, transform=_test_transform)
|
||||||
def test_bp():
|
def test_bp():
|
||||||
"""Test nginx configuration without reloading.
|
"""POST /api/proxy/test — Test nginx configuration without reloading."""
|
||||||
|
|
||||||
POST /api/proxy/test
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"valid": true, "output": ...}`` on success. Returns 400 if test fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = post(POST_NGINX_TEST)
|
|
||||||
if result.get("valid"):
|
|
||||||
return _ok({"valid": True, "output": result.get("output", "")})
|
|
||||||
return _error(result.get("output", "unknown error"), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("nginx config test failed: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -276,102 +154,40 @@ def test_bp():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/backends", methods=["GET"])
|
def _backend_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"backend": sent.get("name")}
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(GET_NGINX_BACKENDS, bp)
|
||||||
def list_backends():
|
def list_backends():
|
||||||
"""List all configured backends.
|
"""GET /api/proxy/backends — List all configured backends (secrets stripped)."""
|
||||||
|
|
||||||
GET /api/proxy/backends
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict of backend configs with secrets stripped.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_NGINX_BACKENDS))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list backends: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/backends", methods=["PATCH"])
|
@daemon_route(
|
||||||
|
PATCH_NGINX_BACKENDS, bp, precheck=require_dict_body, transform=_backend_echo
|
||||||
|
)
|
||||||
def patch_backend_bp():
|
def patch_backend_bp():
|
||||||
"""Partially update a backend entry.
|
"""PATCH /api/proxy/backends — Partially update a backend entry."""
|
||||||
|
|
||||||
PATCH /api/proxy/backends
|
|
||||||
|
|
||||||
Body fields:
|
|
||||||
name: Backend name.
|
|
||||||
label: Optional new label.
|
|
||||||
paths: Optional new paths dict.
|
|
||||||
auth: Optional new auth config.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"backend": ...}`` on success.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
patch(PATCH_NGINX_BACKENDS, body)
|
|
||||||
logger.info("Backend '%s' patched via API", body.get("name"))
|
|
||||||
return _ok({"backend": body.get("name")})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Backend patch rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to patch backend: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/backends", methods=["POST"])
|
def _add_backend_precheck(json: Any, _va: Any) -> None:
|
||||||
|
if not ((json or {}).get("name") or "").strip():
|
||||||
|
raise ValueError("'name' is required")
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_NGINX_BACKENDS_ADD,
|
||||||
|
bp,
|
||||||
|
rule="/backends",
|
||||||
|
precheck=_add_backend_precheck,
|
||||||
|
transform=_backend_echo,
|
||||||
|
)
|
||||||
def add_backend_bp():
|
def add_backend_bp():
|
||||||
"""Add a new backend.
|
"""POST /api/proxy/backends — Add a new backend."""
|
||||||
|
|
||||||
POST /api/proxy/backends
|
|
||||||
|
|
||||||
Body fields:
|
|
||||||
name: Backend name (slug, unique).
|
|
||||||
label: Human-readable label.
|
|
||||||
paths: Path-to-config map.
|
|
||||||
auth: Optional auth config.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"backend": ...}`` on success.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
name = body.get("name", "").strip()
|
|
||||||
if not name:
|
|
||||||
return _error("'name' is required", 400)
|
|
||||||
try:
|
|
||||||
post(POST_NGINX_BACKENDS_ADD, body)
|
|
||||||
logger.info("Backend added via API: %s", name)
|
|
||||||
return _ok({"backend": name})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Add backend '%s' rejected: %s", name, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add backend '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/backends/<name>", methods=["DELETE"])
|
@daemon_route(
|
||||||
def remove_backend_bp(name):
|
DELETE_NGINX_BACKENDS_REMOVE, bp, rule="/backends/<name>", transform=_backend_echo
|
||||||
"""Remove a non-builtin backend.
|
)
|
||||||
|
def remove_backend_bp():
|
||||||
DELETE /api/proxy/backends/<name>
|
"""DELETE /api/proxy/backends/<name> — Remove a non-builtin backend."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"backend": ...}`` on success.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
delete(DELETE_NGINX_BACKENDS_REMOVE, {"name": name})
|
|
||||||
logger.info("Backend removed via API: %s", name)
|
|
||||||
return _ok({"backend": name})
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Remove backend '%s' rejected: %s", name, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except Conflict as exc:
|
|
||||||
logger.info("Remove backend '%s' conflict: %s", name, exc)
|
|
||||||
return _error(str(exc), 409)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove backend '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
+16
-83
@@ -3,13 +3,12 @@
|
|||||||
Exposed at /api/status/* and delegates all operations to vacuum-walld.
|
Exposed at /api/status/* and delegates all operations to vacuum-walld.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from flask import Blueprint
|
||||||
|
|
||||||
import logging
|
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
|
||||||
|
get,
|
||||||
from flask import Blueprint, request
|
post,
|
||||||
|
)
|
||||||
from daemon.client import get, post
|
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
GET_STATUS_PENDING,
|
GET_STATUS_PENDING,
|
||||||
GET_SYSTEM_METRICS,
|
GET_SYSTEM_METRICS,
|
||||||
@@ -17,97 +16,31 @@ from daemon.iface import (
|
|||||||
POST_STATUS_CANCEL_ALL,
|
POST_STATUS_CANCEL_ALL,
|
||||||
POST_STATUS_REFRESH,
|
POST_STATUS_REFRESH,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import NO_BODY, daemon_route
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("status", __name__)
|
bp = Blueprint("status", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/pending", methods=["GET"])
|
@daemon_route(GET_STATUS_PENDING, bp)
|
||||||
def pending():
|
def pending():
|
||||||
"""Retrieve aggregate pending changes across all subsystems.
|
"""GET /api/status/pending — Per-subsystem pending status + total change count."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/status/pending
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with per-subsystem pending status and total change count.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_STATUS_PENDING))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get pending status: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/apply-all", methods=["POST"])
|
@daemon_route(POST_STATUS_APPLY_ALL, bp)
|
||||||
def apply_all():
|
def apply_all():
|
||||||
"""Apply pending changes for all subsystems in dependency order.
|
"""POST /api/status/apply-all — Apply pending changes in dependency order."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/status/apply-all
|
|
||||||
Body:
|
|
||||||
{"force": true} (optional) — overrides the firewall safety guards
|
|
||||||
(management lockout, interface coverage) for this apply.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with applied subsystems list and any errors encountered.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True)
|
|
||||||
try:
|
|
||||||
return _ok(post(POST_STATUS_APPLY_ALL, body))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to apply all pending changes: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/cancel-all", methods=["POST"])
|
@daemon_route(POST_STATUS_CANCEL_ALL, bp, body=NO_BODY)
|
||||||
def cancel_all():
|
def cancel_all():
|
||||||
"""Revert pending changes for all subsystems to the last applied config.
|
"""POST /api/status/cancel-all — Revert pending changes to last applied config."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/status/cancel-all
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the reverted subsystems, skipped subsystems
|
|
||||||
(label -> reason), and any errors encountered.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(post(POST_STATUS_CANCEL_ALL))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to cancel all pending changes: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/refresh", methods=["POST"])
|
@daemon_route(POST_STATUS_REFRESH, bp)
|
||||||
def refresh():
|
def refresh():
|
||||||
"""Re-collect state from the daemon, optionally filtered by subsystem.
|
"""POST /api/status/refresh — Re-collect state, optionally filtered by subsystem."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
POST /api/status/refresh
|
|
||||||
Body:
|
|
||||||
{"subsystems": ["firewall"]} or {} for all.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
try:
|
|
||||||
return _ok(post(POST_STATUS_REFRESH, body))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to refresh state: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/system-metrics", methods=["GET"])
|
@daemon_route(GET_SYSTEM_METRICS, bp, rule="/system-metrics")
|
||||||
def system_metrics():
|
def system_metrics():
|
||||||
"""Retrieve system-wide metrics.
|
"""GET /api/status/system-metrics — System-wide CPU/memory/network metrics."""
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
GET /api/status/system-metrics
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with CPU load, memory usage, and network traffic stats.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_SYSTEM_METRICS))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get system metrics: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
+204
-425
@@ -3,11 +3,16 @@
|
|||||||
Exposed at /api/wireguard/* and delegates to vacuum-walld.
|
Exposed at /api/wireguard/* and delegates to vacuum-walld.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
|
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||||
|
delete,
|
||||||
|
get,
|
||||||
|
patch,
|
||||||
|
post,
|
||||||
|
)
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_WIREGUARD_CLASSES,
|
DELETE_WIREGUARD_CLASSES,
|
||||||
DELETE_WIREGUARD_CLASSES_DOWN,
|
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||||
@@ -30,208 +35,31 @@ from daemon.iface import (
|
|||||||
POST_WIREGUARD_INITIALIZE,
|
POST_WIREGUARD_INITIALIZE,
|
||||||
POST_WIREGUARD_PEERS_ADD,
|
POST_WIREGUARD_PEERS_ADD,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("wireguard", __name__)
|
bp = Blueprint("wireguard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["GET"])
|
# ---------------------------------------------------------------------------
|
||||||
def get_config_bp():
|
# Body builders / prechecks / transforms
|
||||||
"""Get the current WireGuard configuration.
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Endpoint: GET /api/wireguard/config
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the WireGuard config on success, or an error
|
|
||||||
response on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_WIREGUARD_CONFIG))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to read WireGuard config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["POST"])
|
def _wg_config_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def post_config():
|
|
||||||
"""Create or fully replace the WireGuard configuration.
|
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/config
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON body with the configuration. If an ``interface`` key
|
|
||||||
is present, the private key will be stripped before forwarding.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success response on acceptance, 400 on validation failure, or 500
|
|
||||||
on server error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
if "interface" in body:
|
if "interface" in body:
|
||||||
body = dict(body)
|
body = dict(body)
|
||||||
body["interface"] = dict(body["interface"])
|
body["interface"] = dict(body["interface"])
|
||||||
body["interface"].pop("private_key", None)
|
body["interface"].pop("private_key", None)
|
||||||
post(POST_WIREGUARD_CONFIG, body)
|
return body
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("WireGuard config save rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to save WireGuard config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config", methods=["PATCH"])
|
def _add_peer_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def patch_config():
|
|
||||||
"""Partially update the WireGuard configuration.
|
|
||||||
|
|
||||||
Endpoint: PATCH /api/wireguard/config
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body: JSON body with the fields to update. If an ``interface``
|
|
||||||
key is present, the private key will be stripped before forwarding.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success response on acceptance, 400 on validation failure, or 500
|
|
||||||
on server error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
if not isinstance(body, dict):
|
name = (body.get("name") or "").strip()
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
try:
|
|
||||||
if "interface" in body:
|
|
||||||
body = dict(body)
|
|
||||||
body["interface"] = dict(body["interface"])
|
|
||||||
body["interface"].pop("private_key", None)
|
|
||||||
patch(PATCH_WIREGUARD_CONFIG, body)
|
|
||||||
logger.info("WireGuard config patched: %s", sorted(body.keys()))
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("WireGuard config patch rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to patch WireGuard config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/apply", methods=["POST"])
|
|
||||||
def apply_bp():
|
|
||||||
"""Apply the current WireGuard configuration to the live tunnel.
|
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/apply
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success response on acceptance, or 500 on server error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
post(POST_WIREGUARD_APPLY)
|
|
||||||
logger.info("WireGuard tunnel applied via API")
|
|
||||||
return _ok(None)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to apply WireGuard config: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/up", methods=["POST"])
|
|
||||||
def up_bp():
|
|
||||||
"""Bring the WireGuard tunnel interface up.
|
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/up
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success response on acceptance, or 500 on server error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
post(POST_WIREGUARD_APPLY)
|
|
||||||
logger.info("WireGuard tunnel started via API")
|
|
||||||
return _ok(None)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to start WireGuard tunnel: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/down", methods=["POST"])
|
|
||||||
def down_bp():
|
|
||||||
"""Bring the WireGuard tunnel interface down.
|
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/down
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success response on acceptance, or 500 on server error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
post(POST_WIREGUARD_DOWN)
|
|
||||||
logger.info("WireGuard tunnel brought down via API")
|
|
||||||
return _ok(None)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/status", methods=["GET"])
|
|
||||||
def status_bp():
|
|
||||||
"""Get the current WireGuard tunnel status.
|
|
||||||
|
|
||||||
Endpoint: GET /api/wireguard/status
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the tunnel status on success, or an error
|
|
||||||
response on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_WIREGUARD_STATUS))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get WireGuard status: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/initialize", methods=["POST"])
|
|
||||||
def initialize_bp():
|
|
||||||
"""Initialize WireGuard for first-time use.
|
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/initialize
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success response on acceptance, or 500 on server error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
post(POST_WIREGUARD_INITIALIZE)
|
|
||||||
logger.info("WireGuard initialized via API")
|
|
||||||
return _ok(None)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to initialize WireGuard: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/peers", methods=["POST"])
|
|
||||||
def add_peer_bp():
|
|
||||||
"""Add a new peer to the WireGuard configuration.
|
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/peers
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Peer display name (required).
|
|
||||||
endpoint: Optional peer endpoint address.
|
|
||||||
allowed_ips: Optional list of allowed IP CIDRs.
|
|
||||||
persistent_keepalive: Optional keepalive interval in seconds.
|
|
||||||
preshared_key: Optional pre-shared key in hex.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the created peer on success, 400 on validation
|
|
||||||
failure, or 500 on server error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
name = body.get("name", "").strip()
|
|
||||||
if not name:
|
if not name:
|
||||||
return _error("'name' is required", 400)
|
raise ValueError("'name' is required")
|
||||||
try:
|
return {
|
||||||
peer = post(
|
|
||||||
POST_WIREGUARD_PEERS_ADD,
|
|
||||||
{
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"endpoint": body.get("endpoint"),
|
"endpoint": body.get("endpoint"),
|
||||||
"allowed_ips": body.get("allowed_ips", []),
|
"allowed_ips": body.get("allowed_ips", []),
|
||||||
@@ -239,264 +67,215 @@ def add_peer_bp():
|
|||||||
"preshared_key": body.get("preshared_key"),
|
"preshared_key": body.get("preshared_key"),
|
||||||
"description": body.get("description"),
|
"description": body.get("description"),
|
||||||
"access_class": body.get("access_class"),
|
"access_class": body.get("access_class"),
|
||||||
},
|
}
|
||||||
)
|
|
||||||
logger.info("WireGuard peer '%s' added via API", name)
|
|
||||||
return _ok(peer)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Add peer '%s' rejected: %s", name, exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add peer '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/peers/<name>", methods=["DELETE"])
|
def _gen_client_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
def remove_peer_bp(name):
|
|
||||||
"""Remove a peer from the WireGuard configuration.
|
|
||||||
|
|
||||||
Endpoint: DELETE /api/wireguard/peers/<name>
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Peer name to remove (from URL path).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success response with peer name on removal, 404 if peer not found,
|
|
||||||
or 500 on server error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
delete(DELETE_WIREGUARD_PEERS_REMOVE, {"name": name})
|
|
||||||
logger.info("WireGuard peer '%s' removed via API", name)
|
|
||||||
return _ok({"name": name})
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("WireGuard peer '%s' not found: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to remove peer '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/peers", methods=["GET"])
|
|
||||||
def peers_bp():
|
|
||||||
"""List all configured WireGuard peers.
|
|
||||||
|
|
||||||
Endpoint: GET /api/wireguard/peers
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the peers list on success, or an error response
|
|
||||||
on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_WIREGUARD_PEERS))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list WireGuard peers: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/peer-status", methods=["GET"])
|
|
||||||
def peer_status_bp():
|
|
||||||
"""Get real-time status information for all WireGuard peers.
|
|
||||||
|
|
||||||
Endpoint: GET /api/wireguard/peer-status
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with peer status on success, or an error response
|
|
||||||
on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_WIREGUARD_PEER_STATUS))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get WireGuard peer status: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/generate-client", methods=["POST"])
|
|
||||||
def generate_client_bp():
|
|
||||||
"""Generate a WireGuard client configuration file for a peer.
|
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/generate-client
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Peer name (required).
|
|
||||||
server_endpoint: Server endpoint address for the client config (required).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with the generated config string on success, 404 if
|
|
||||||
peer not found, 400 on validation failure, or 500 on server error.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
name = body.get("name", "").strip()
|
name = (body.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return _error("Field 'name' is required", 400)
|
raise ValueError("Field 'name' is required")
|
||||||
server_endpoint = body.get("server_endpoint", "")
|
server_endpoint = body.get("server_endpoint", "")
|
||||||
if not server_endpoint:
|
if not server_endpoint:
|
||||||
return _error("Field 'server_endpoint' is required", 400)
|
raise ValueError("Field 'server_endpoint' is required")
|
||||||
try:
|
return {"name": name, "server_endpoint": server_endpoint}
|
||||||
result = post(
|
|
||||||
POST_WIREGUARD_GENERATE_CLIENT,
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"server_endpoint": server_endpoint,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info("Client config generated for peer '%s' via API", name)
|
|
||||||
return _ok({"config": result.get("config", "")})
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Peer '%s' not found for client config: %s", name, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to generate client config for '%s': %s", name, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes", methods=["GET"])
|
def _delete_class_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
key = (body.get("key") or "").strip()
|
||||||
|
if not key:
|
||||||
|
raise ValueError("'key' is required")
|
||||||
|
return {"key": key}
|
||||||
|
|
||||||
|
|
||||||
|
def _class_key_precheck(json: Any, va: dict[str, Any]) -> None:
|
||||||
|
require_dict_body(json, va)
|
||||||
|
if not ((json or {}).get("key") or "").strip():
|
||||||
|
raise ValueError("'key' is required")
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_name_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
|
return {"name": sent["name"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _config_echo(data: Any, _va: Any, _sent: Any) -> Any:
|
||||||
|
return {"config": data.get("config", "")}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(GET_WIREGUARD_CONFIG, bp)
|
||||||
|
def get_config_bp():
|
||||||
|
"""GET /api/wireguard/config — Get the current WireGuard configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_WIREGUARD_CONFIG,
|
||||||
|
bp,
|
||||||
|
precheck=require_dict_body,
|
||||||
|
body=_wg_config_body,
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
|
def post_config():
|
||||||
|
"""POST /api/wireguard/config — Create or fully replace the configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
PATCH_WIREGUARD_CONFIG,
|
||||||
|
bp,
|
||||||
|
precheck=require_dict_body,
|
||||||
|
body=_wg_config_body,
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
|
def patch_config():
|
||||||
|
"""PATCH /api/wireguard/config — Partially update the configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tunnel control
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_WIREGUARD_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||||
|
def apply_bp():
|
||||||
|
"""POST /api/wireguard/apply — Apply the current configuration to the tunnel."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_WIREGUARD_APPLY, bp, rule="/up", body=NO_BODY, transform=void_transform
|
||||||
|
)
|
||||||
|
def up_bp():
|
||||||
|
"""POST /api/wireguard/up — Bring the WireGuard tunnel interface up."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_WIREGUARD_DOWN, bp, body=NO_BODY, transform=void_transform)
|
||||||
|
def down_bp():
|
||||||
|
"""POST /api/wireguard/down — Bring the WireGuard tunnel interface down."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(GET_WIREGUARD_STATUS, bp)
|
||||||
|
def status_bp():
|
||||||
|
"""GET /api/wireguard/status — Get the current WireGuard tunnel status."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_WIREGUARD_INITIALIZE, bp, body=NO_BODY, transform=void_transform)
|
||||||
|
def initialize_bp():
|
||||||
|
"""POST /api/wireguard/initialize — Initialize WireGuard for first-time use."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Peers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(POST_WIREGUARD_PEERS_ADD, bp, rule="/peers", body=_add_peer_body)
|
||||||
|
def add_peer_bp():
|
||||||
|
"""POST /api/wireguard/peers — Add a new peer to the configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
DELETE_WIREGUARD_PEERS_REMOVE, bp, rule="/peers/<name>", transform=_peer_name_echo
|
||||||
|
)
|
||||||
|
def remove_peer_bp():
|
||||||
|
"""DELETE /api/wireguard/peers/<name> — Remove a peer from the configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(GET_WIREGUARD_PEERS, bp)
|
||||||
|
def peers_bp():
|
||||||
|
"""GET /api/wireguard/peers — List all configured WireGuard peers."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(GET_WIREGUARD_PEER_STATUS, bp)
|
||||||
|
def peer_status_bp():
|
||||||
|
"""GET /api/wireguard/peer-status — Get real-time status for all peers."""
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_WIREGUARD_GENERATE_CLIENT, bp, body=_gen_client_body, transform=_config_echo
|
||||||
|
)
|
||||||
|
def generate_client_bp():
|
||||||
|
"""POST /api/wireguard/generate-client — Generate a client config for a peer."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Access classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(GET_WIREGUARD_CLASSES, bp)
|
||||||
def list_classes_bp():
|
def list_classes_bp():
|
||||||
"""List all access classes.
|
"""GET /api/wireguard/classes — List all access classes."""
|
||||||
|
|
||||||
Endpoint: GET /api/wireguard/classes
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(get(GET_WIREGUARD_CLASSES))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to list access classes: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes", methods=["POST"])
|
@daemon_route(POST_WIREGUARD_CLASSES, bp, precheck=_class_key_precheck)
|
||||||
def create_class_bp():
|
def create_class_bp():
|
||||||
"""Create a new access class.
|
"""POST /api/wireguard/classes — Create a new access class."""
|
||||||
|
|
||||||
Endpoint: POST /api/wireguard/classes
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
key = body.get("key", "").strip()
|
|
||||||
if not key:
|
|
||||||
return _error("'key' is required", 400)
|
|
||||||
try:
|
|
||||||
result = post(POST_WIREGUARD_CLASSES, body)
|
|
||||||
return _ok(result)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Create access class rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except Conflict as exc:
|
|
||||||
logger.info("Create access class conflict: %s", exc)
|
|
||||||
return _error(str(exc), 409)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to create access class: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes", methods=["PATCH"])
|
@daemon_route(
|
||||||
|
PATCH_WIREGUARD_CLASSES, bp, rule="/classes", precheck=_class_key_precheck
|
||||||
|
)
|
||||||
def update_class_bp():
|
def update_class_bp():
|
||||||
"""Update an access class.
|
"""PATCH /api/wireguard/classes — Update an access class."""
|
||||||
|
|
||||||
Endpoint: PATCH /api/wireguard/classes
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
key = body.get("key", "").strip()
|
|
||||||
if not key:
|
|
||||||
return _error("'key' is required", 400)
|
|
||||||
try:
|
|
||||||
result = patch(PATCH_WIREGUARD_CLASSES, body)
|
|
||||||
return _ok(result)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Update access class rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Access class not found: %s", exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to update access class: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes", methods=["DELETE"])
|
@daemon_route(
|
||||||
|
DELETE_WIREGUARD_CLASSES,
|
||||||
|
bp,
|
||||||
|
rule="/classes",
|
||||||
|
precheck=require_dict_body,
|
||||||
|
body=_delete_class_body,
|
||||||
|
)
|
||||||
def delete_class_bp():
|
def delete_class_bp():
|
||||||
"""Delete an access class.
|
"""DELETE /api/wireguard/classes — Delete an access class."""
|
||||||
|
|
||||||
Endpoint: DELETE /api/wireguard/classes
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
return _error("Request body must be a JSON object", 400)
|
|
||||||
key = body.get("key", "").strip()
|
|
||||||
if not key:
|
|
||||||
return _error("'key' is required", 400)
|
|
||||||
try:
|
|
||||||
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
|
|
||||||
logger.info("Access class '%s' deleted via API", key)
|
|
||||||
return _ok(result)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Access class not found: %s", exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except Conflict as exc:
|
|
||||||
logger.info("Delete access class conflict: %s", exc)
|
|
||||||
return _error(str(exc), 409)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to delete access class: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes/<key>/up", methods=["POST"])
|
@daemon_route(
|
||||||
def class_up_bp(key):
|
POST_WIREGUARD_CLASSES_UP,
|
||||||
"""Bring up a single access class's WireGuard tunnel.
|
bp,
|
||||||
|
rule="/classes/<key>/up",
|
||||||
Endpoint: POST /api/wireguard/classes/<key>/up
|
params={"class_key": "key"},
|
||||||
"""
|
body={},
|
||||||
try:
|
transform=void_transform,
|
||||||
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
|
)
|
||||||
logger.info("WireGuard class '%s' tunnel brought up via API", key)
|
def class_up_bp():
|
||||||
return _ok(None)
|
"""POST /api/wireguard/classes/<key>/up — Bring up a class's tunnel."""
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to bring up class '%s': %s", key, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes/<key>/down", methods=["POST"])
|
@daemon_route(
|
||||||
def class_down_bp(key):
|
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||||
"""Bring down a single access class's WireGuard tunnel.
|
bp,
|
||||||
|
rule="/classes/<key>/down",
|
||||||
Endpoint: POST /api/wireguard/classes/<key>/down
|
methods=["POST"],
|
||||||
"""
|
params={"class_key": "key"},
|
||||||
try:
|
body={},
|
||||||
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
|
transform=void_transform,
|
||||||
logger.info("WireGuard class '%s' tunnel brought down via API", key)
|
)
|
||||||
return _ok(None)
|
def class_down_bp():
|
||||||
except RuntimeError as exc:
|
"""POST /api/wireguard/classes/<key>/down — Bring down a class's tunnel."""
|
||||||
logger.error("Failed to bring down class '%s': %s", key, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes/<key>/status", methods=["GET"])
|
@daemon_route(
|
||||||
def class_status_bp(key):
|
GET_WIREGUARD_CLASS_STATUS,
|
||||||
"""Get status for a single access class's tunnel.
|
bp,
|
||||||
|
rule="/classes/<key>/status",
|
||||||
Endpoint: GET /api/wireguard/classes/<key>/status
|
params={"class_key": "key"},
|
||||||
"""
|
)
|
||||||
try:
|
def class_status_bp():
|
||||||
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
|
"""GET /api/wireguard/classes/<key>/status — Get status for a class's tunnel."""
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to get class '%s' status: %s", key, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/classes/keys/<key>", methods=["POST"])
|
@daemon_route(
|
||||||
def class_init_keys_bp(key):
|
POST_WIREGUARD_CLASS_INIT_KEYS,
|
||||||
"""Generate key pair for a single access class.
|
bp,
|
||||||
|
rule="/classes/keys/<key>",
|
||||||
Endpoint: POST /api/wireguard/classes/keys/<key>
|
params={"class_key": "key"},
|
||||||
"""
|
body={},
|
||||||
try:
|
transform=void_transform,
|
||||||
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
|
)
|
||||||
logger.info("WireGuard class '%s' keys generated via API", key)
|
def class_init_keys_bp():
|
||||||
return _ok(None)
|
"""POST /api/wireguard/classes/keys/<key> — Generate keys for a class."""
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Class '%s' not found for keys: %s", key, exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to generate keys for class '%s': %s", key, exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ async function openApplyModal(successMsg) {
|
|||||||
<div class="modal-body">${rows}</div>
|
<div class="modal-body">${rows}</div>
|
||||||
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
|
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
|
||||||
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
|
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
|
||||||
<span class="text-sm">Force apply <span class="text-muted">— overrides firewall safety guards (e.g. leaving an interface in no zone, or removing https/ssh from the default zone)</span></span>
|
<span class="text-sm">Force apply <span class="text-muted">— overrides firewall safety guards (e.g. removing an interface from all zones, or removing https/ssh from the default zone)</span></span>
|
||||||
</label>` : ''}
|
</label>` : ''}
|
||||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
|
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
|
||||||
</div>`);
|
</div>`);
|
||||||
|
|||||||
Reference in New Issue
Block a user