Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc478a016e | |||
| d78b90db00 | |||
| 7e6fd71bdc | |||
| faa076370d | |||
| 89b64960f3 | |||
| 75b86fd60d |
@@ -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).
|
||||||
@@ -63,7 +68,7 @@ Conventions:
|
|||||||
- `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
|
- `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
|
||||||
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
|
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
|
||||||
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
|
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
|
||||||
- No build step — ES modules served raw. Cache controlled via HTTP headers.
|
- No build step — ES modules served raw. Cache controlled via HTTP headers. For the management domain, nginx serves `/static/` directly from `webui/static/` (generated `location /static/` alias with `no-cache` + ETag revalidation); Flask's static route is the dev-mode fallback.
|
||||||
|
|
||||||
### Daemon Endpoints
|
### Daemon Endpoints
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
+64
-13
@@ -54,6 +54,52 @@ _ACME_ENVIRON = {
|
|||||||
|
|
||||||
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_acme_home() -> None:
|
||||||
|
"""Restore group access on the ACME home files around acme.sh runs.
|
||||||
|
|
||||||
|
acme.sh hardens its tree on every run (``chmod 700`` on the config
|
||||||
|
home, ``chmod 600`` on keys and confs, owned by the running user).
|
||||||
|
The daemon reopens group read/write via the sudoers whitelist so
|
||||||
|
the shared two-user model keeps the tree readable. Run BEFORE an
|
||||||
|
acme.sh invocation too: acme.sh dot-sources ``account.conf`` on
|
||||||
|
startup, so a tree left owner-only by another user's run (e.g. a
|
||||||
|
manual debug run as the WebUI user) would make every daemon acme.sh
|
||||||
|
call exit 2 — normalizing first is the only self-heal path, since a
|
||||||
|
post-run normalize is unreachable while acme.sh cannot start.
|
||||||
|
|
||||||
|
Files only: the directories in the tree are setgid (2775, group rwx
|
||||||
|
already), and chmodding a setgid directory issues fchmodat with the
|
||||||
|
S_ISGID bit set, which the unit's ``RestrictSUIDSGID=yes`` seccomp
|
||||||
|
filter rejects with EPERM even for root.
|
||||||
|
"""
|
||||||
|
files = [str(p) for p in _ACME_HOME.rglob("*") if p.is_file()]
|
||||||
|
if not files:
|
||||||
|
return
|
||||||
|
result = lib_common.run_proc(
|
||||||
|
["chmod", "g+rwX", *files],
|
||||||
|
sudo=True,
|
||||||
|
check=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(
|
||||||
|
"Could not normalize ACME_HOME permissions: %s",
|
||||||
|
result.stderr.strip() or f"exit code {result.returncode}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_acme_preflight(args: list[str]) -> str:
|
||||||
|
"""Normalize ACME home permissions, then run acme.sh with *args*.
|
||||||
|
|
||||||
|
Single choke point for every daemon acme.sh invocation: the
|
||||||
|
preflight normalize makes the run succeed even if a prior run by
|
||||||
|
another user left the tree owner-only.
|
||||||
|
"""
|
||||||
|
normalize_acme_home()
|
||||||
|
return _run_acme(args)
|
||||||
|
|
||||||
|
|
||||||
# In-memory store for active issuance requests.
|
# In-memory store for active issuance requests.
|
||||||
_ISSUANCES: dict[str, "IssueRequest"] = {}
|
_ISSUANCES: dict[str, "IssueRequest"] = {}
|
||||||
|
|
||||||
@@ -546,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"):
|
||||||
@@ -561,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"):
|
||||||
@@ -576,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)
|
||||||
|
|
||||||
@@ -839,14 +885,16 @@ async def _run_issue(req: IssueRequest) -> None:
|
|||||||
args.append("--force")
|
args.append("--force")
|
||||||
# acme.sh is a blocking subprocess — run it off the event loop so
|
# acme.sh is a blocking subprocess — run it off the event loop so
|
||||||
# polling, WS broadcasts, and other requests keep responding.
|
# polling, WS broadcasts, and other requests keep responding.
|
||||||
output = await asyncio.to_thread(_run_acme, args)
|
output = await asyncio.to_thread(_run_acme_preflight, args)
|
||||||
|
normalize_acme_home()
|
||||||
req.steps[0].status = "done"
|
req.steps[0].status = "done"
|
||||||
req.steps[0].message = output.strip()[:200]
|
req.steps[0].message = output.strip()[:200]
|
||||||
|
|
||||||
# Step 2: deploy
|
# Step 2: deploy
|
||||||
req.steps[1].status = "running"
|
req.steps[1].status = "running"
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
|
_run_acme_preflight,
|
||||||
|
["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK],
|
||||||
)
|
)
|
||||||
req.steps[1].status = "done"
|
req.steps[1].status = "done"
|
||||||
req.steps[1].message = "Deploy hook registered"
|
req.steps[1].message = "Deploy hook registered"
|
||||||
@@ -951,7 +999,9 @@ async def _run_renew(req: IssueRequest, force: bool) -> None:
|
|||||||
args: list[str] = ["--renew", "-d", req.domain]
|
args: list[str] = ["--renew", "-d", req.domain]
|
||||||
if force:
|
if force:
|
||||||
args.append("--force")
|
args.append("--force")
|
||||||
output = await asyncio.to_thread(_run_acme, args)
|
output = await asyncio.to_thread(_run_acme_preflight, args)
|
||||||
|
# acme.sh hardens its tree even when it skips — normalize first.
|
||||||
|
normalize_acme_home()
|
||||||
if "Skipping." in output:
|
if "Skipping." in output:
|
||||||
req.steps[0].status = "done"
|
req.steps[0].status = "done"
|
||||||
req.steps[0].message = "Renewal not yet due — skipped"
|
req.steps[0].message = "Renewal not yet due — skipped"
|
||||||
@@ -968,7 +1018,8 @@ async def _run_renew(req: IssueRequest, force: bool) -> None:
|
|||||||
# Step 2: deploy
|
# Step 2: deploy
|
||||||
req.steps[1].status = "running"
|
req.steps[1].status = "running"
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
|
_run_acme_preflight,
|
||||||
|
["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK],
|
||||||
)
|
)
|
||||||
req.steps[1].status = "done"
|
req.steps[1].status = "done"
|
||||||
req.steps[1].message = "Deploy hook registered"
|
req.steps[1].message = "Deploy hook registered"
|
||||||
@@ -1003,7 +1054,7 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
domain = body.get("domain", "").strip()
|
domain = body.get("domain", "").strip()
|
||||||
if not domain:
|
if not domain:
|
||||||
raise ValueError("'domain' is required")
|
raise ValueError("'domain' is required")
|
||||||
_run_acme(["--remove", "-d", domain])
|
_run_acme_preflight(["--remove", "-d", domain])
|
||||||
logger.info("Certificate for %s removed", domain)
|
logger.info("Certificate for %s removed", domain)
|
||||||
refresh_state(["acme"])
|
refresh_state(["acme"])
|
||||||
return {"domain": domain}
|
return {"domain": domain}
|
||||||
@@ -1021,7 +1072,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
email = body.get("email", "").strip()
|
email = body.get("email", "").strip()
|
||||||
if not email:
|
if not email:
|
||||||
raise ValueError("'email' is required")
|
raise ValueError("'email' is required")
|
||||||
_run_acme(["--register-account", "-m", email])
|
_run_acme_preflight(["--register-account", "-m", email])
|
||||||
# Persist to declarative ACME config
|
# Persist to declarative ACME config
|
||||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||||
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
|
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1159,7 +1210,7 @@ def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
raise ValueError("Invalid email format")
|
raise ValueError("Invalid email format")
|
||||||
server = (body.get("server") or "letsencrypt").strip()
|
server = (body.get("server") or "letsencrypt").strip()
|
||||||
|
|
||||||
_run_acme(["--register-account", "-m", email, "--server", server])
|
_run_acme_preflight(["--register-account", "-m", email, "--server", server])
|
||||||
|
|
||||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||||
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
|
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1179,7 +1230,7 @@ def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
def deactivate_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def deactivate_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""DELETE /acme/account/deactivate — deactivate the ACME account."""
|
"""DELETE /acme/account/deactivate — deactivate the ACME account."""
|
||||||
try:
|
try:
|
||||||
_run_acme(["--deactivate-account"])
|
_run_acme_preflight(["--deactivate-account"])
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
logger.warning("acme.sh deactivate failed: %s", exc)
|
logger.warning("acme.sh deactivate failed: %s", exc)
|
||||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||||
|
|||||||
@@ -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"]}
|
||||||
|
|||||||
+131
-136
@@ -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,38 +310,36 @@ 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", [])
|
||||||
),
|
for iface in current_ifaces:
|
||||||
).get("interfaces", [])
|
run(
|
||||||
for iface in current_ifaces:
|
[
|
||||||
run(
|
"firewall-cmd",
|
||||||
[
|
f"--zone={zone_name}",
|
||||||
"firewall-cmd",
|
"--remove-interface=" + iface,
|
||||||
f"--zone={zone_name}",
|
"--permanent",
|
||||||
"--remove-interface=" + iface,
|
],
|
||||||
"--permanent",
|
sudo=True,
|
||||||
],
|
check=False,
|
||||||
sudo=True,
|
)
|
||||||
check=False,
|
for iface in zone_cfg.get("interfaces", []):
|
||||||
)
|
run(
|
||||||
for iface in zone_cfg.get("interfaces", []):
|
[
|
||||||
run(
|
"firewall-cmd",
|
||||||
[
|
f"--zone={zone_name}",
|
||||||
"firewall-cmd",
|
"--add-interface=" + iface,
|
||||||
f"--zone={zone_name}",
|
"--permanent",
|
||||||
"--add-interface=" + iface,
|
],
|
||||||
"--permanent",
|
sudo=True,
|
||||||
],
|
)
|
||||||
sudo=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
|
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
|
||||||
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
|
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
|
||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
@@ -856,15 +860,13 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
old_zone_cfg["interfaces"] = new_ifaces
|
old_zone_cfg["interfaces"] = new_ifaces
|
||||||
elif "interfaces" in old_zone_cfg:
|
elif "interfaces" in old_zone_cfg:
|
||||||
del old_zone_cfg["interfaces"]
|
del old_zone_cfg["interfaces"]
|
||||||
|
# This mutation already applied to live firewalld, so re-stamp the applied
|
||||||
|
# baseline: cancel-all must revert to this state, not an older snapshot.
|
||||||
|
stamp_applied(cfg)
|
||||||
_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}
|
||||||
|
|
||||||
|
|
||||||
@@ -927,15 +929,15 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
|||||||
_reload()
|
_reload()
|
||||||
|
|
||||||
# Keep the declarative config in sync so the next apply does not
|
# Keep the declarative config in sync so the next apply does not
|
||||||
# reconcile the live services back to the stale config value.
|
# reconcile the live services back to the stale config value. The
|
||||||
|
# mutation already applied to live firewalld, so re-stamp the applied
|
||||||
|
# baseline: cancel-all must revert to this state, not an older snapshot.
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
|
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
@@ -979,13 +981,9 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
rule_id = uuid4().hex[:8]
|
rule_id = uuid4().hex[:8]
|
||||||
entry = {"id": rule_id, "rule": rule}
|
entry = {"id": rule_id, "rule": rule}
|
||||||
cfg["zones"][zone]["rich_rules"].append(entry)
|
cfg["zones"][zone]["rich_rules"].append(entry)
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
@@ -1035,13 +1033,9 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
zone_cfg["rich_rules"] = [
|
zone_cfg["rich_rules"] = [
|
||||||
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
|
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
|
||||||
]
|
]
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
@@ -1084,6 +1078,10 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
|
|||||||
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Enable/disable masquerade on a zone.
|
"""Enable/disable masquerade on a zone.
|
||||||
|
|
||||||
|
Also syncs the declarative config (and re-stamps the applied baseline)
|
||||||
|
when the zone exists in the config, so the pending diff and cancel-all
|
||||||
|
stay consistent with the live zone.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
_request: The incoming HTTP request (unused).
|
_request: The incoming HTTP request (unused).
|
||||||
body: JSON body with ``zone`` and ``enable`` (boolean).
|
body: JSON body with ``zone`` and ``enable`` (boolean).
|
||||||
@@ -1108,12 +1106,17 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
|||||||
action = "--add-masquerade" if enable else "--remove-masquerade"
|
action = "--add-masquerade" if enable else "--remove-masquerade"
|
||||||
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
|
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
|
||||||
_reload()
|
_reload()
|
||||||
sync_result = bus.emit(
|
# Keep the declarative config in sync with the live zone so the pending
|
||||||
SyncEvent(
|
# diff and the cancel-all baseline stay consistent. Only touch zones that
|
||||||
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
|
# already exist in the config — creating a bare zone entry would
|
||||||
)
|
# manufacture spurious service/interface diffs on the next poll.
|
||||||
)
|
cfg = _get_config()
|
||||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
zone_cfg = cfg.get("zones", {}).get(zone)
|
||||||
|
if isinstance(zone_cfg, dict):
|
||||||
|
zone_cfg["masquerade"] = bool(enable)
|
||||||
|
stamp_applied(cfg)
|
||||||
|
_save_config(cfg)
|
||||||
|
emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone})
|
||||||
return {"zone": zone, "masquerade": bool(enable)}
|
return {"zone": zone, "masquerade": bool(enable)}
|
||||||
|
|
||||||
|
|
||||||
@@ -1161,20 +1164,16 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
_reload()
|
_reload()
|
||||||
fp_id = uuid4().hex[:8]
|
fp_id = uuid4().hex[:8]
|
||||||
entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto}
|
entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto}
|
||||||
if toaddr:
|
if toaddr and toport:
|
||||||
entry["toaddr"] = toaddr
|
entry["toaddr"] = toaddr
|
||||||
if toport:
|
if toport:
|
||||||
entry["toport"] = int(toport)
|
entry["toport"] = int(toport)
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
|
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
|
||||||
cfg["zones"][zone]["forward_ports"].append(entry)
|
cfg["zones"][zone]["forward_ports"].append(entry)
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
@@ -1233,13 +1232,9 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
cfg["zones"][zone]["forward_ports"] = [
|
cfg["zones"][zone]["forward_ports"] = [
|
||||||
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
|
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
|
||||||
]
|
]
|
||||||
|
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}
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any])
|
|||||||
cert_key_path=cert_key_path,
|
cert_key_path=cert_key_path,
|
||||||
domain_auth=_ngx_resolve_auth(domain_cfg, backends),
|
domain_auth=_ngx_resolve_auth(domain_cfg, backends),
|
||||||
has_management=has_management,
|
has_management=has_management,
|
||||||
|
static_root=str(PROJECT_DIR / "webui" / "static"),
|
||||||
acme_cert_dir=acme_cert_dir,
|
acme_cert_dir=acme_cert_dir,
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
|
|||||||
@@ -128,12 +128,20 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
Order: network -> firewall -> wireguard -> dnsmasq -> nginx.
|
Order: network -> firewall -> wireguard -> dnsmasq -> nginx.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_request: The incoming HTTP request (unused).
|
||||||
|
_body: Optional JSON body; ``{"force": true}`` is forwarded to the
|
||||||
|
firewall apply, overriding its management-lockout and
|
||||||
|
interface-coverage guards. Other subsystems ignore it.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with applied subsystems and any errors encountered.
|
Dict with applied subsystems and any errors encountered.
|
||||||
"""
|
"""
|
||||||
applied = []
|
applied = []
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
|
force = bool(_body and _body.get("force"))
|
||||||
|
|
||||||
pending_data = status_pending(None, None)
|
pending_data = status_pending(None, None)
|
||||||
fw_pending = pending_data["firewall"]["needs_apply"]
|
fw_pending = pending_data["firewall"]["needs_apply"]
|
||||||
hash_pending = {
|
hash_pending = {
|
||||||
@@ -153,7 +161,10 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
handler = SYS_APPLY[name]
|
handler = SYS_APPLY[name]
|
||||||
try:
|
try:
|
||||||
handler(None, None)
|
# Only the firewall apply honors `force` (its lockout and
|
||||||
|
# coverage guards); forward it there, not to other subsystems.
|
||||||
|
body = {"force": True} if (name == "firewall" and force) else None
|
||||||
|
handler(None, body)
|
||||||
applied.append(name)
|
applied.append(name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
label = SYS_LABELS.get(name, name)
|
label = SYS_LABELS.get(name, name)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+30
-18
@@ -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,16 +146,20 @@ 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
|
||||||
for name in targets:
|
if bump:
|
||||||
state_store.bump(name)
|
for name in targets:
|
||||||
|
state_store.bump(name)
|
||||||
try:
|
try:
|
||||||
asyncio.get_running_loop()
|
asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -698,7 +692,7 @@ def main() -> None:
|
|||||||
_stop_polling()
|
_stop_polling()
|
||||||
# Suppress the default exception handler during teardown so that
|
# Suppress the default exception handler during teardown so that
|
||||||
# cancelling in-flight tasks does not spew tracebacks on SIGTERM.
|
# cancelling in-flight tasks does not spew tracebacks on SIGTERM.
|
||||||
prev_handler = loop.exception_handler
|
prev_handler = loop.get_exception_handler()
|
||||||
loop.set_exception_handler(_teardown_exception_handler)
|
loop.set_exception_handler(_teardown_exception_handler)
|
||||||
try:
|
try:
|
||||||
# Stop accepting new connections (also waits for open sockets,
|
# Stop accepting new connections (also waits for open sockets,
|
||||||
@@ -747,6 +741,24 @@ 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
|
||||||
|
# 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
|
||||||
|
# call until the next issue/renew. Never fatal at startup.
|
||||||
|
try:
|
||||||
|
from daemon.handlers.acme import normalize_acme_home
|
||||||
|
|
||||||
|
normalize_acme_home()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("ACME home normalization failed at startup", exc_info=True)
|
||||||
|
|
||||||
# Populate state from system (blocking — OK at startup)
|
# Populate state from system (blocking — OK at startup)
|
||||||
logger.info("Populating system state...")
|
logger.info("Populating system state...")
|
||||||
state_store.populate()
|
state_store.populate()
|
||||||
|
|||||||
+40
-5
@@ -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 |
|
||||||
@@ -1976,6 +1986,16 @@ POST /api/status/apply-all
|
|||||||
|
|
||||||
Apply pending changes for all subsystems in dependency order.
|
Apply pending changes for all subsystems in dependency order.
|
||||||
|
|
||||||
|
**Request Body (optional):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "force": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`force` is forwarded to the firewall apply only — it overrides the
|
||||||
|
management-lockout guard and the interface-coverage invariant. Other
|
||||||
|
subsystems ignore it.
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -1983,10 +2003,17 @@ Apply pending changes for all subsystems in dependency order.
|
|||||||
| `applied` | `[string, ...]` | List of subsystems that were applied |
|
| `applied` | `[string, ...]` | List of subsystems that were applied |
|
||||||
| `errors` | `object` | Map of subsystem label → error message |
|
| `errors` | `object` | Map of subsystem label → error message |
|
||||||
|
|
||||||
The firewall apply runs with `force=false`, so if a firewall interface
|
The endpoint returns `200` even when some subsystems failed — per-subsystem
|
||||||
would be left without zone coverage (the coverage guard), a
|
failures are reported in `errors`, so clients must check `errors` (not just
|
||||||
`ConflictError` surfaces in `errors` under `"Firewall"` while the other
|
the HTTP status) before reporting success. Without `force`, the firewall
|
||||||
subsystems proceed — the desired no-silent-apply behavior.
|
apply is refused when a network-managed interface has no zone coverage in
|
||||||
|
the config and is not `unmanaged` (the interface-coverage invariant) or when
|
||||||
|
the config would strip both https/ssh from the default zone (lockout guard);
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2003,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`):**
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
|
|||||||
|
|
||||||
```
|
```
|
||||||
Client requests / ──→ nginx ──→ Flask (serves index.html)
|
Client requests / ──→ nginx ──→ Flask (serves index.html)
|
||||||
Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
|
Client loads /static/app.js ──→ served by nginx directly from disk (mgmt `location /static/` alias, no Flask round-trip) ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
|
||||||
Authenticated ──→ mounts #sidebar and #main render roots
|
Authenticated ──→ mounts #sidebar and #main render roots
|
||||||
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
||||||
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
||||||
@@ -348,7 +348,7 @@ Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ─
|
|||||||
WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes
|
WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes
|
||||||
```
|
```
|
||||||
|
|
||||||
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries.
|
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries. On the management domain, nginx serves `/static/` directly from `webui/static/` via a `location /static/` alias in the generated server block, so asset requests never reach Flask in production; Flask's static route remains as the dev-mode fallback.
|
||||||
|
|
||||||
### Component Model
|
### Component Model
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ Each route is a `definePage()` component with reactive state, async data loading
|
|||||||
|
|
||||||
### No Build Step
|
### No Build Step
|
||||||
|
|
||||||
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
|
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers: the management domain's `/static/` assets carry `Cache-Control: no-cache` (browsers revalidate every load; unchanged files return 304 via nginx's built-in ETag), so updates are picked up on the next page load. Dev mode (`VACUUM_WALL_DEV`) uses short TTLs instead.
|
||||||
|
|
||||||
### WebSocket Data Streaming
|
### WebSocket Data Streaming
|
||||||
|
|
||||||
|
|||||||
+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.
|
||||||
|
|
||||||
|
|||||||
+74
-5
@@ -677,7 +677,15 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
|
|||||||
|
|
||||||
### `toast(message, type, duration)`
|
### `toast(message, type, duration)`
|
||||||
|
|
||||||
Show a toast notification. Auto-dismisses after `duration` ms (default 4000). `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID.
|
Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID.
|
||||||
|
|
||||||
|
When `duration` is omitted, per-type defaults apply: `'info'` and `'success'` auto-dismiss after 4000 ms, `'warning'` after 8000 ms, and `'error'` toasts **never** auto-dismiss (they stay until dismissed so long failure messages remain readable). Pass an explicit `duration` (ms, `0` = indefinite) to override the default.
|
||||||
|
|
||||||
|
Toast behavior:
|
||||||
|
|
||||||
|
- Dismissal is only via the `×` button (or `dismissToast(id)`); clicking the toast body does not dismiss it.
|
||||||
|
- The auto-dismiss timer pauses while the pointer is over the toast.
|
||||||
|
- Long messages (>200 chars or containing newlines) render compact — first line, ellipsized — with a **Details** button that opens a modal showing the full text in a scrollable mono block.
|
||||||
|
|
||||||
### `dismissToast(id)`
|
### `dismissToast(id)`
|
||||||
|
|
||||||
@@ -947,9 +955,10 @@ StatusText({ status: iface.state })
|
|||||||
|
|
||||||
Empty-state placeholder card.
|
Empty-state placeholder card.
|
||||||
|
|
||||||
#### `Card({ header, children })`
|
#### `Card({ header, children, cls, title })`
|
||||||
|
|
||||||
Card container with optional header.
|
Card container with optional header. `cls` appends a class to the outer
|
||||||
|
`div.card`; `title` sets a tooltip on the outer div.
|
||||||
|
|
||||||
#### `ConfirmDelete(props)`
|
#### `ConfirmDelete(props)`
|
||||||
|
|
||||||
@@ -1152,9 +1161,9 @@ ZoneSelect({
|
|||||||
| `onChange` | `(zone) => void` callback |
|
| `onChange` | `(zone) => void` callback |
|
||||||
| `placeholder` | Placeholder option text (optional) |
|
| `placeholder` | Placeholder option text (optional) |
|
||||||
|
|
||||||
#### `Table({ columns, rows, emptyText, wrapCard, key })`
|
#### `Table({ columns, rows, emptyText, wrapCard, key, cls, title })`
|
||||||
|
|
||||||
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes.
|
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes. `cls` appends a class to the wrapper (or `div.card`); `title` sets a tooltip on the wrapper.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
Table({
|
Table({
|
||||||
@@ -1337,6 +1346,66 @@ re-apply the current state instead of losing it.
|
|||||||
|
|
||||||
Render the toast notification container. Include in the main render root. See API section above.
|
Render the toast notification container. Include in the main render root. See API section above.
|
||||||
|
|
||||||
|
## Dirty / pending-edit markers
|
||||||
|
|
||||||
|
`dirty.js` marks UI elements that have been edited (saved to config) but not yet
|
||||||
|
applied to the live system. It consumes the pending state the daemon already
|
||||||
|
streams — no extra API calls. Visual language: amber accent (`.config-dirty`) +
|
||||||
|
`PendingDot` + tooltip, distinct from the red `.pending-delete` (deletion) style.
|
||||||
|
|
||||||
|
#### `PendingDot()`
|
||||||
|
|
||||||
|
Small amber dot marking a pending (edited, not yet applied) element. Drop it into
|
||||||
|
the first cell of a dirty row, or next to a card/section heading.
|
||||||
|
|
||||||
|
### Hash subsystems (field-level)
|
||||||
|
|
||||||
|
Pending source: `status.pending_diff` — `[{path, action, old, new}]` where `path`
|
||||||
|
is a dotted config path (e.g. `dhcp.ranges[0].start`, `interface.listen_port`,
|
||||||
|
`domains.example.local.cert`).
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|---|---|
|
||||||
|
| `dirtySet(status)` | `Set` of pending config paths from a subsystem `status` object (reads `status.pending_diff`; empty set when absent). When `status.pending_changes` is true but `pending_diff` is empty (config saved but never applied — no baseline to diff), the set is a *sentinel* that marks every element dirty |
|
||||||
|
| `isDirty(set, path)` | `true` when element path `path` is on a pending line (under / above / equal to a pending path); always `true` for the never-applied sentinel |
|
||||||
|
| `dirtyTitle(set, path)` | Tooltip text listing the concrete pending field(s) that affect `path` (empty string when clean); the sentinel reads "Configuration saved but not applied yet" |
|
||||||
|
| `dirtyInfo(set, path)` | `{dirty, class, title}` — `class` is `'config-dirty'` or `''`, `title` the tooltip or `''`. One object per element; apply `class`/`title` on the element |
|
||||||
|
| `orphanInfo(set, root, children)` | `{dirty, class, title}` for a container element: dirty when a pending path under `root` has **no** live child element to mark — e.g. a removed dict key (`peers.p1`) whose row no longer exists. `children` is the list of element paths for the container's live children (e.g. `'peers.' + name`). Clean when the set is the never-applied sentinel or when `root` itself is pending (every row is marked instead) |
|
||||||
|
|
||||||
|
**Line-matching rule**: an element path is dirty when it shares a root-to-leaf
|
||||||
|
line with a pending path — equal, an ancestor, or a descendant. A plain key is a
|
||||||
|
prefix of its indexed form (`ranges` prefixes `ranges[0]`), so a whole-list
|
||||||
|
change (e.g. `dhcp.ranges`) marks every row of that list, while a leaf change
|
||||||
|
(`interface.listen_port`) marks only that field/row. Matching is segment-based,
|
||||||
|
so dotted names (e.g. a domain `a.com.b`) can conservatively over-highlight a
|
||||||
|
parent-like row — never a false negative.
|
||||||
|
|
||||||
|
### Firewall (zone + type)
|
||||||
|
|
||||||
|
Pending source: `pending` — `{needs_apply, pending: [{zone, type, ...}]}` where
|
||||||
|
`type` ∈ `interfaces|services|target|masquerade|rich_rules|forward_ports`
|
||||||
|
(zone-level, not field-level).
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|---|---|
|
||||||
|
| `fwDirty(pending)` | `Map<zone, Set<type>>` from a firewall `pending` object (empty map when absent) |
|
||||||
|
| `fwIsDirty(map, zone, type?)` | `true` when `zone` (and optionally `type`) has a pending change |
|
||||||
|
| `fwTitle(map, zone, type?)` | Tooltip listing the pending type(s) for the zone (empty string when clean) |
|
||||||
|
| `fwInfo(map, zone, type?)` | `{dirty, class, title}` — one object for a firewall element (zone, optional type) |
|
||||||
|
|
||||||
|
### Wiring conventions
|
||||||
|
|
||||||
|
- Compute the set **once** per `render()`, after the guard:
|
||||||
|
`const set = dirtySet(state.<subsystem>.data?.status)` or
|
||||||
|
`const fw = fwDirty(state.firewall.data?.pending)`.
|
||||||
|
- `h()` rows/cards: merge `{ class: info.class, title: info.title }` into the props object.
|
||||||
|
- `htm` rows/cards: `class="row ${info.class}"` + `title=${info.title || undefined}`;
|
||||||
|
drop `PendingDot({})` into the first cell when `info.dirty`.
|
||||||
|
- Container elements (tables/sections) whose children are dict keys: pass
|
||||||
|
`orphanInfo(set, root, childPaths)` as `cls`/`title` so removed entries —
|
||||||
|
which leave no row to mark — still surface on the container (WireGuard peers table).
|
||||||
|
- An empty `class`/`title` is harmless; prefer `|| undefined` for htm attrs.
|
||||||
|
|
||||||
## Helpers
|
## Helpers
|
||||||
|
|
||||||
| Function | Description |
|
| Function | Description |
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ The `daemon/client.py` module resolves `<param>` placeholders in URL paths befor
|
|||||||
|
|
||||||
### Management Interface
|
### Management Interface
|
||||||
|
|
||||||
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain.
|
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain. Static assets under `/static/` are served directly by nginx from `webui/static/` (unauthenticated, the same exposure as the Flask static route) with `Cache-Control: no-cache`, `X-Content-Type-Options: nosniff`, and a restrictive `Content-Security-Policy: default-src 'none'`.
|
||||||
|
|
||||||
JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
|
JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -24,10 +24,14 @@ return annotation references them.
|
|||||||
differs from the recorded hash; `status.pending_diff` lists the field
|
differs from the recorded hash; `status.pending_diff` lists the field
|
||||||
changes since that snapshot. All apply operations (including firewall
|
changes since that snapshot. All apply operations (including firewall
|
||||||
`config_apply`) re-stamp the baseline. These bookkeeping keys are
|
`config_apply`) re-stamp the baseline. These bookkeeping keys are
|
||||||
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
|
||||||
|
|
||||||
@@ -62,10 +66,13 @@ Top-level `FirewallState`:
|
|||||||
// firewalld service XML definitions
|
// firewalld service XML definitions
|
||||||
// (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",
|
||||||
|
|||||||
+30
-1
@@ -96,6 +96,17 @@ def _run_acme(args: list[str]) -> str:
|
|||||||
"--config-home",
|
"--config-home",
|
||||||
acme_home_env,
|
acme_home_env,
|
||||||
*args,
|
*args,
|
||||||
|
# Append the full transcript to $ACME_HOME/acme.sh.log so manual
|
||||||
|
# runs (whose stdout is captured below) leave a persistent record
|
||||||
|
# of the raw CA exchange. The log file is passed explicitly (never
|
||||||
|
# as a bare trailing --log): a valueless trailing --log makes
|
||||||
|
# acme.sh's arg loop double-shift under dash (the --log branch
|
||||||
|
# shifts once, then the loop's trailing `shift 1` runs with zero
|
||||||
|
# positional params) and fails with "shift: can't shift that many"
|
||||||
|
# (exit 2). The explicit path keeps the same default destination
|
||||||
|
# ($LE_CONFIG_HOME/acme.sh.log) and can never swallow a real arg.
|
||||||
|
"--log",
|
||||||
|
str(Path(acme_home_env) / "acme.sh.log"),
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -118,12 +129,30 @@ def _run_acme(args: list[str]) -> str:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
|
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"acme.sh failed with exit code {result.returncode}: {output.strip()}"
|
f"acme.sh failed with exit code {result.returncode}: "
|
||||||
|
f"{_summarize_acme_output(output)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_acme_output(output: str) -> str:
|
||||||
|
"""Reduce raw acme.sh output to a concise, human-readable summary.
|
||||||
|
|
||||||
|
acme.sh prints timestamped transcript lines; the failure reason is
|
||||||
|
in the final lines (e.g. "The retryafter=86400 value is too large
|
||||||
|
(> 600), will not retry anymore."). Strips per-line timestamps and
|
||||||
|
the "Please check log file" pointer so the summary stays toast-
|
||||||
|
sized. The full transcript remains in the log and acme.sh.log.
|
||||||
|
"""
|
||||||
|
lines = [line.strip() for line in output.strip().splitlines() if line.strip()]
|
||||||
|
lines = [re.sub(r"^\[[^\]]*\] ", "", line) for line in lines]
|
||||||
|
lines = [line for line in lines if not line.startswith("Please check log file")]
|
||||||
|
if not lines:
|
||||||
|
return "(no output)"
|
||||||
|
return "; ".join(lines[-2:])
|
||||||
|
|
||||||
|
|
||||||
def set_email(email: str) -> None:
|
def set_email(email: str) -> None:
|
||||||
"""Configure the default ACME contact email.
|
"""Configure the default ACME contact email.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+72
-20
@@ -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,18 +394,19 @@ 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
|
||||||
cfg_ifaces = set(zone_cfg.get("interfaces", []))
|
# key counts as an empty list, so every config zone is diffed.
|
||||||
live_ifaces = set(live_zone.get("interfaces", []))
|
cfg_ifaces = set(zone_cfg.get("interfaces", []))
|
||||||
if cfg_ifaces != live_ifaces:
|
live_ifaces = set(live_zone.get("interfaces", []))
|
||||||
changes.append(
|
if cfg_ifaces != live_ifaces:
|
||||||
{
|
changes.append(
|
||||||
"zone": zone_name,
|
{
|
||||||
"type": "interfaces",
|
"zone": zone_name,
|
||||||
"config": sorted(cfg_ifaces),
|
"type": "interfaces",
|
||||||
"live": sorted(live_ifaces),
|
"config": sorted(cfg_ifaces),
|
||||||
}
|
"live": sorted(live_ifaces),
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
|
||||||
cfg_services = set(zone_cfg.get("services", []))
|
cfg_services = set(zone_cfg.get("services", []))
|
||||||
live_services = set(live_zone.get("services", []))
|
live_services = set(live_zone.get("services", []))
|
||||||
@@ -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:
|
||||||
|
|||||||
+28
-6
@@ -169,15 +169,17 @@ 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)
|
||||||
@@ -185,9 +187,27 @@ def get_config() -> dict[str, Any]:
|
|||||||
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)
|
||||||
raw = _migrate_config(raw)
|
raw = _migrate_config(raw)
|
||||||
save_config(raw)
|
if raw != pre:
|
||||||
return raw
|
save_config(raw)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict[str, Any]) -> None:
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
@@ -396,6 +416,7 @@ def generate_server_conf(
|
|||||||
cert_key_path=cert_key_path,
|
cert_key_path=cert_key_path,
|
||||||
domain_auth=domain_auth,
|
domain_auth=domain_auth,
|
||||||
has_management=has_management,
|
has_management=has_management,
|
||||||
|
static_root=str(PROJECT_DIR / "webui" / "static"),
|
||||||
acme_cert_dir=acme_cert_dir,
|
acme_cert_dir=acme_cert_dir,
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
@@ -610,6 +631,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",
|
||||||
|
|||||||
+4
-1
@@ -280,15 +280,18 @@ class AcmeState(TypedDict):
|
|||||||
"""ACME state (collector: `_collect_acme`).
|
"""ACME state (collector: `_collect_acme`).
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
certs: Certificate list.
|
certs: Certificate list (empty when collection failed).
|
||||||
email: Registered ACME email.
|
email: Registered ACME email.
|
||||||
account: Account status (see AcmeAccount).
|
account: Account status (see AcmeAccount).
|
||||||
|
status: Collection status; ``error`` is ``None`` on success or
|
||||||
|
the failure message when cert collection was not possible.
|
||||||
timestamp: ISO-8601 collection time.
|
timestamp: ISO-8601 collection time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
certs: list[AcmeCert]
|
certs: list[AcmeCert]
|
||||||
email: str
|
email: str
|
||||||
account: AcmeAccount
|
account: AcmeAccount
|
||||||
|
status: dict[str, str | None]
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-993
File diff suppressed because it is too large
Load Diff
+34
-4
@@ -8,10 +8,18 @@ caused by install.sh or manual edits to system files.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from lib.common import load_json, run, save_json
|
from lib.common import (
|
||||||
|
_APPLY_HASH_KEY,
|
||||||
|
_LAST_APPLIED_CONFIG_KEY,
|
||||||
|
load_json,
|
||||||
|
run,
|
||||||
|
save_json,
|
||||||
|
stamp_applied,
|
||||||
|
)
|
||||||
from lib.firewall import _live_target_to_config, _parse_all_zones_output
|
from lib.firewall import _live_target_to_config, _parse_all_zones_output
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -53,6 +61,24 @@ def import_all() -> list[str]:
|
|||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _carry_apply_meta(cfg: dict[str, Any], existing: dict[str, Any]) -> None:
|
||||||
|
"""Preserve apply bookkeeping when adopting live system state.
|
||||||
|
|
||||||
|
Imported content replaces the declarative config but must not destroy
|
||||||
|
the applied-state baseline. When *existing* carries apply meta keys,
|
||||||
|
they are copied over so pending-change detection and cancel-all keep
|
||||||
|
working against the last-applied baseline. When no baseline exists
|
||||||
|
(first import), *cfg* is stamped as applied — the imported content is
|
||||||
|
exactly the state the system is currently running.
|
||||||
|
"""
|
||||||
|
if _APPLY_HASH_KEY in existing or _LAST_APPLIED_CONFIG_KEY in existing:
|
||||||
|
for key in (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY):
|
||||||
|
if key in existing:
|
||||||
|
cfg[key] = deepcopy(existing[key])
|
||||||
|
else:
|
||||||
|
stamp_applied(cfg)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Dnsmasq
|
# Dnsmasq
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -86,12 +112,13 @@ def import_dnsmasq() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
|
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
|
||||||
|
existing: dict[str, Any] = {}
|
||||||
if cfg_path.exists():
|
if cfg_path.exists():
|
||||||
existing = load_json(cfg_path)
|
existing = load_json(cfg_path)
|
||||||
if _cfgs_equal(existing, cfg):
|
if _cfgs_equal(existing, cfg):
|
||||||
logger.debug("Skipping dnsmasq: config already matches")
|
logger.debug("Skipping dnsmasq: config already matches")
|
||||||
return False
|
return False
|
||||||
|
_carry_apply_meta(cfg, existing)
|
||||||
save_json(cfg_path, cfg)
|
save_json(cfg_path, cfg)
|
||||||
summary = f"upstreams={len(cfg.get('dns', {}).get('upstreams', []))}, ranges={len(cfg.get('dhcp', {}).get('ranges', []))}"
|
summary = f"upstreams={len(cfg.get('dns', {}).get('upstreams', []))}, ranges={len(cfg.get('dhcp', {}).get('ranges', []))}"
|
||||||
logger.info("Imported dnsmasq config from %s: %s", DNSMASQ_CONF, summary)
|
logger.info("Imported dnsmasq config from %s: %s", DNSMASQ_CONF, summary)
|
||||||
@@ -247,12 +274,13 @@ def import_wireguard() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||||
|
existing: dict[str, Any] = {}
|
||||||
if cfg_path.exists():
|
if cfg_path.exists():
|
||||||
existing = load_json(cfg_path)
|
existing = load_json(cfg_path)
|
||||||
if _cfgs_equal(existing, cfg):
|
if _cfgs_equal(existing, cfg):
|
||||||
logger.debug("Skipping wireguard: config already matches")
|
logger.debug("Skipping wireguard: config already matches")
|
||||||
return False
|
return False
|
||||||
|
_carry_apply_meta(cfg, existing)
|
||||||
save_json(cfg_path, cfg)
|
save_json(cfg_path, cfg)
|
||||||
peer_count = len(cfg.get("peers", {}))
|
peer_count = len(cfg.get("peers", {}))
|
||||||
logger.info("Imported wireguard config from %s: peers=%d", WG_CONF, peer_count)
|
logger.info("Imported wireguard config from %s: peers=%d", WG_CONF, peer_count)
|
||||||
@@ -941,7 +969,9 @@ def import_firewall() -> bool:
|
|||||||
logger.debug("Skipping firewall: no zones with interfaces")
|
logger.debug("Skipping firewall: no zones with interfaces")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
save_json(cfg_path, {"zones": zone_configs})
|
# Only reached when the config file is absent: the imported zones are
|
||||||
|
# exactly what firewalld is running, so stamp them as the applied state.
|
||||||
|
save_json(cfg_path, stamp_applied({"zones": zone_configs}))
|
||||||
logger.info(
|
logger.info(
|
||||||
"Imported firewall config: zones=%s",
|
"Imported firewall config: zones=%s",
|
||||||
", ".join(zone_configs.keys()),
|
", ".join(zone_configs.keys()),
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
@@ -144,6 +144,32 @@ fi
|
|||||||
# Shared group: use the WebUI user's primary group
|
# Shared group: use the WebUI user's primary group
|
||||||
USER_GROUP=$(id -gn "$USER_NAME")
|
USER_GROUP=$(id -gn "$USER_NAME")
|
||||||
|
|
||||||
|
# Some appliance images ship with top-level system directories (and sometimes
|
||||||
|
# everything under them) owned by a regular user. This trips systemd-tmpfiles'
|
||||||
|
# "unsafe path transition" check and lets that user modify system paths.
|
||||||
|
# Repair the top level here; warn with a full-repair command if deeper
|
||||||
|
# mis-ownership is detected (depth-1 entries of /etc /usr /var /boot are
|
||||||
|
# always root-owned on Debian, so this check cannot false-positive).
|
||||||
|
_sys_dirs=(/ /bin /boot /etc /home /media /mnt /opt /root /sbin /srv /usr /var /var/lib /var/log)
|
||||||
|
_misowned=()
|
||||||
|
for _d in "${_sys_dirs[@]}"; do
|
||||||
|
[[ -e "$_d" ]] || continue
|
||||||
|
[[ "$(stat -c '%U' "$_d" 2>/dev/null)" == "root" ]] || _misowned+=("$_d")
|
||||||
|
done
|
||||||
|
if [[ ${#_misowned[@]} -gt 0 ]]; then
|
||||||
|
warn "System directories not owned by root: ${_misowned[*]}"
|
||||||
|
warn "Chowning to root:root (image shipped with mis-owned system paths)."
|
||||||
|
chown root:root "${_misowned[@]}"
|
||||||
|
_deep_count=$(find /etc /usr /var /boot -maxdepth 1 ! -user root 2>/dev/null | wc -l)
|
||||||
|
if [[ "$_deep_count" -gt 0 ]]; then
|
||||||
|
warn "Deeper mis-ownership detected ($_deep_count entries at depth 1)."
|
||||||
|
warn "Run a full repair, then re-run this installer:"
|
||||||
|
warn " sudo find / -xdev -path /proc -prune -o -path /sys -prune -o -path /dev -prune -o -path /run -prune -o -path /tmp -prune -o -path /home/$USER_NAME -prune -o -user $USER_NAME -print0 | xargs -0 -r chown root:root"
|
||||||
|
else
|
||||||
|
log "Repaired top-level system directory ownership."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " Vacuum Wall Appliance Installer"
|
echo " Vacuum Wall Appliance Installer"
|
||||||
echo " Install dir: $PROJECT_DIR"
|
echo " Install dir: $PROJECT_DIR"
|
||||||
@@ -212,6 +238,15 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
|
|||||||
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
|
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
|
||||||
mkdir -p /etc/wireguard
|
mkdir -p /etc/wireguard
|
||||||
mkdir -p /etc/dnsmasq
|
mkdir -p /etc/dnsmasq
|
||||||
|
# nginx workers (www-data) serve webui/static directly from disk for the
|
||||||
|
# management domain — ensure read access regardless of checkout umask.
|
||||||
|
chmod -R a+rX "${PROJECT_DIR}/webui/static"
|
||||||
|
# ...and traversal (x only) up the parent chain, so repo-in-$HOME installs work.
|
||||||
|
_d="${PROJECT_DIR}"
|
||||||
|
while [[ "$d" != "/" && -n "$d" ]]; do
|
||||||
|
chmod a+x "$d" 2>/dev/null || true
|
||||||
|
d="$(dirname "$d")"
|
||||||
|
done
|
||||||
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev.
|
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev.
|
||||||
# The top-level .git (directory or worktree pointer file) is left untouched so
|
# The top-level .git (directory or worktree pointer file) is left untouched so
|
||||||
# the repo owner's git isn't tripped by git's dubious-ownership check.
|
# the repo owner's git isn't tripped by git's dubious-ownership check.
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ server {
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for ppath, pcfg in paths.items() %}
|
{% for ppath, pcfg in paths.items() %}
|
||||||
|
{% if pcfg.is_management and ppath == '/' %}
|
||||||
|
# SPA static assets — served from disk, no Flask round-trip.
|
||||||
|
# no-cache: browsers revalidate every load; unchanged files are 304s.
|
||||||
|
location /static/ {
|
||||||
|
alias {{ static_root }}/;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'" always;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
{% if pcfg.is_websocket %}
|
{% if pcfg.is_websocket %}
|
||||||
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket)
|
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket)
|
||||||
location {{ ppath }} {
|
location {{ ppath }} {
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
|
|||||||
# Sysctl
|
# Sysctl
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/sysctl -w *
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/sysctl -w *
|
||||||
|
|
||||||
|
# ACME home permissions (acme.sh chmods its tree to owner-only modes:
|
||||||
|
# 700 on the config home, 600 on keys/confs — group access must be
|
||||||
|
# reopened so the shared two-user model can read the tree). Files only:
|
||||||
|
# the setgid directories (2775) already grant group rwx, and chmodding
|
||||||
|
# them would trip the daemon unit's RestrictSUIDSGID seccomp filter.
|
||||||
|
# The trailing * spans the file argument list.
|
||||||
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chmod g+rwX {{ ACME_HOME }}/*
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ Description=Vacuum Wall ACME Certificate Renewal
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User={{ USER_NAME }}
|
# Run as the daemon user, not the WebUI user: it owns the project tree
|
||||||
|
# (and the ACME home) in production, and acme.sh chmods its config home
|
||||||
|
# to 700 and its keys/confs to 600 on every run. Running as the WebUI
|
||||||
|
# user left the tree unreadable to the daemon (and vice versa) whenever
|
||||||
|
# the two users' runs interleaved.
|
||||||
|
User={{ USER_DAEMON_NAME }}
|
||||||
WorkingDirectory={{ PROJECT_DIR }}
|
WorkingDirectory={{ PROJECT_DIR }}
|
||||||
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||||
Environment=HOME={{ PROJECT_DIR }}
|
Environment=HOME={{ PROJECT_DIR }}
|
||||||
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }}
|
# --log: persistent on-disk transcript of the raw CA exchange (journald
|
||||||
|
# captures stdout regardless; the file survives journal retention).
|
||||||
|
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }} --log
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
Description=Vacuum Wall ACME Certificate Renewal Timer
|
Description=Vacuum Wall ACME Certificate Renewal Timer
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
|
# Daily only: ZeroSSL backs off a failed validation for 24h per domain
|
||||||
|
# (Retry-After: 86400). With two runs a day every attempt landed inside
|
||||||
|
# the previous attempt's backoff window, re-arming it — a permanent
|
||||||
|
# renewal lockout. Attempts >24h apart are required for the backoff to
|
||||||
|
# ever expire (acme.sh discussion #6419).
|
||||||
OnCalendar=*-*-* 00:00:00
|
OnCalendar=*-*-* 00:00:00
|
||||||
OnCalendar=*-*-* 12:00:00
|
|
||||||
Persistent=true
|
Persistent=true
|
||||||
RandomizedDelaySec=300
|
RandomizedDelaySec=300
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ TimeoutStopSec=15
|
|||||||
Environment=PATH=/usr/local/bin:/usr/bin
|
Environment=PATH=/usr/local/bin:/usr/bin
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||||
|
# acme.sh routes its _info/_err lines through logger(1) -> journald when
|
||||||
|
# SYS_LOG is set (default: off). This journals manual issue/renew runs
|
||||||
|
# in real time under this unit, whose subprocess stdout is otherwise
|
||||||
|
# captured by the daemon and never seen by the journal.
|
||||||
|
# Levels: 3=error, 6=info, 7=debug.
|
||||||
|
Environment=SYS_LOG=6
|
||||||
Environment=HOME={{ PROJECT_DIR }}
|
Environment=HOME={{ PROJECT_DIR }}
|
||||||
|
|
||||||
# Runtime directories created before namespace setup. ProtectSystem=strict
|
# Runtime directories created before namespace setup. ProtectSystem=strict
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
|
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
|
import { buildRows, isPending, SUBSYSTEM_LIST, applyResultToasts } from '../webui/static/hoover/components/applyconfirm.js';
|
||||||
|
|
||||||
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
|
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
|
||||||
|
|
||||||
@@ -235,5 +235,38 @@ test('buildRows row VNodes have correct tag', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === applyResultToasts ===
|
||||||
|
// apply-all returns 200 with { applied, errors } even when subsystems
|
||||||
|
// failed — resp.ok alone is not a success signal; errors must win.
|
||||||
|
test('applyResultToasts: errors suppress the success toast', () => {
|
||||||
|
const t = applyResultToasts({ applied: ['Network'], errors: { Firewall: 'refused' } }, 'All changes applied');
|
||||||
|
assertEq(t.success, null, 'no success toast when errors exist');
|
||||||
|
assertIncludes(t.error, 'Firewall — refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyResultToasts: success toast when applied and no errors', () => {
|
||||||
|
const t = applyResultToasts({ applied: ['Firewall', 'Nginx'], errors: {} }, 'All changes applied');
|
||||||
|
assertEq(t.error, null);
|
||||||
|
assertEq(t.success, 'All changes applied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyResultToasts: no toast when nothing applied and no errors', () => {
|
||||||
|
const t = applyResultToasts({ applied: [], errors: {} }, 'All changes applied');
|
||||||
|
assertEq(t.error, null);
|
||||||
|
assertEq(t.success, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyResultToasts: multiple errors are joined', () => {
|
||||||
|
const t = applyResultToasts({ applied: [], errors: { Firewall: 'a', Nginx: 'b' } }, 'ok');
|
||||||
|
assertIncludes(t.error, 'Firewall — a');
|
||||||
|
assertIncludes(t.error, 'Nginx — b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyResultToasts: null payload is safe', () => {
|
||||||
|
const t = applyResultToasts(null, 'ok');
|
||||||
|
assertEq(t.error, null);
|
||||||
|
assertEq(t.success, null);
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`\n${passed} passed, ${failed} failed`);
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Tests for hoover/dirty.js — pending-edit marker matching.
|
||||||
|
*
|
||||||
|
* dirty.js has no imports — DOM-free at import, so the tests run under
|
||||||
|
* plain node (same pattern as test-model-set.js).
|
||||||
|
*
|
||||||
|
* Run with `node tests/test-dirty.js`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from '../webui/static/hoover/dirty.js';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const tests = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
tests.push({ name, fn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEq(a, b, msg) {
|
||||||
|
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── dirtySet ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('dirtySet collects pending paths from pending_diff', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [
|
||||||
|
{ path: 'dhcp.ranges[0].start', action: 'changed' },
|
||||||
|
{ path: 'dns.domain', action: 'added' },
|
||||||
|
]});
|
||||||
|
assert(set.has('dhcp.ranges[0].start'), 'first path collected');
|
||||||
|
assert(set.has('dns.domain'), 'second path collected');
|
||||||
|
assertEq(set.size, 2, 'exactly two paths');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirtySet skips diff entries without a path', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [null, {}, { action: 'changed' }, { path: 'a.b' }] });
|
||||||
|
assertEq(set.size, 1, 'only well-formed entries');
|
||||||
|
assert(set.has('a.b'), 'valid path collected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirtySet is empty when pending_diff is absent', () => {
|
||||||
|
assertEq(dirtySet(null).size, 0, 'null status');
|
||||||
|
assertEq(dirtySet({}).size, 0, 'empty status');
|
||||||
|
assertEq(dirtySet({ pending_diff: 'nope' }).size, 0, 'non-array pending_diff');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── never-applied sentinel ──────────────────────────────────── */
|
||||||
|
|
||||||
|
test('dirtySet marks everything dirty when saved but never applied', () => {
|
||||||
|
const set = dirtySet({ pending_changes: true, pending_diff: [] });
|
||||||
|
assertEq(set.size, 1, 'sentinel only');
|
||||||
|
assert(isDirty(set, 'dhcp.ranges[0].start'), 'any path is dirty');
|
||||||
|
assert(isDirty(set, 'interface.listen_port'), 'any other path is dirty');
|
||||||
|
assertEq(dirtyTitle(set, 'dhcp.ranges[0].start'), 'Configuration saved but not applied yet', 'sentinel tooltip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirtySet has no sentinel when there is no pending state', () => {
|
||||||
|
const set = dirtySet({ pending_changes: false, pending_diff: [] });
|
||||||
|
assert(!isDirty(set, 'dhcp.ranges'), 'clean when nothing is pending');
|
||||||
|
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip when clean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirtySet has no sentinel when a real diff exists', () => {
|
||||||
|
const set = dirtySet({
|
||||||
|
pending_changes: true,
|
||||||
|
pending_diff: [{ path: 'dns.domain', action: 'changed' }],
|
||||||
|
});
|
||||||
|
assert(isDirty(set, 'dns.domain'), 'matching path is dirty');
|
||||||
|
assert(!isDirty(set, 'dhcp.ranges'), 'unrelated path stays clean');
|
||||||
|
assertEq(dirtyTitle(set, 'dns.domain'), 'Unapplied changes: dns.domain', 'normal tooltip, not the sentinel');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── line matching ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('isDirty matches an exact pending leaf', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
|
||||||
|
assert(isDirty(set, 'interface.listen_port'), 'equal path is dirty');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pending list marks every indexed row (ancestor of element)', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges', action: 'changed' }] });
|
||||||
|
for (const i of [0, 1, 12]) {
|
||||||
|
assert(isDirty(set, `dhcp.ranges[${i}]`), `row ${i} is dirty`);
|
||||||
|
assert(isDirty(set, `dhcp.ranges[${i}].start`), `row ${i} field is dirty`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pending row field marks the list (descendant of element)', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[0].start', action: 'changed' }] });
|
||||||
|
assert(isDirty(set, 'dhcp.ranges'), 'the list container is dirty');
|
||||||
|
assert(isDirty(set, 'dhcp'), 'the top-level container is dirty');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unrelated paths do not match', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
|
||||||
|
assert(!isDirty(set, 'dhcp.ranges'), 'different root');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index brackets do not prefix-match across digits', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[1]', action: 'changed' }] });
|
||||||
|
assert(!isDirty(set, 'dhcp.ranges[12]'), 'ranges[1] must not mark row 12');
|
||||||
|
assert(!isDirty(set, 'dhcp.ranges[10]'), 'ranges[1] must not mark row 10');
|
||||||
|
assert(isDirty(set, 'dhcp.ranges[1]'), 'the exact row is dirty');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain keys do not prefix-match similar names', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
|
||||||
|
assert(!isDirty(set, 'interfaces.eth0'), 'interface must not mark interfaces.eth0');
|
||||||
|
assert(!isDirty(set, 'interface2.port'), 'interface must not mark interface2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDirty is false for an empty or missing set', () => {
|
||||||
|
assert(!isDirty(new Set(), 'a.b'), 'empty set');
|
||||||
|
assert(!isDirty(null, 'a.b'), 'null set');
|
||||||
|
assert(!isDirty(dirtySet({}), 'a.b'), 'status with no pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDirty tolerates an empty path', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'a.b', action: 'changed' }] });
|
||||||
|
assert(!isDirty(set, ''), 'empty element path is not dirty');
|
||||||
|
assert(!isDirty(set, null), 'null element path is not dirty');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── dirtyTitle / dirtyInfo ──────────────────────────────────── */
|
||||||
|
|
||||||
|
test('dirtyTitle lists all matching pending paths sorted', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [
|
||||||
|
{ path: 'dhcp.ranges[1].start', action: 'changed' },
|
||||||
|
{ path: 'dhcp.ranges[0].end', action: 'changed' },
|
||||||
|
{ path: 'dns.domain', action: 'changed' },
|
||||||
|
]});
|
||||||
|
assertEq(
|
||||||
|
dirtyTitle(set, 'dhcp.ranges'),
|
||||||
|
'Unapplied changes: dhcp.ranges[0].end, dhcp.ranges[1].start',
|
||||||
|
'both rows listed, sorted, unrelated path excluded',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirtyTitle is empty when the element is clean', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
|
||||||
|
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip for unrelated element');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirtyInfo returns the full marker object', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
|
||||||
|
const hit = dirtyInfo(set, 'dns.domain');
|
||||||
|
assertEq(hit.dirty, true, 'dirty flag');
|
||||||
|
assertEq(hit.class, 'config-dirty', 'class');
|
||||||
|
assertEq(hit.title, 'Unapplied changes: dns.domain', 'tooltip');
|
||||||
|
const miss = dirtyInfo(set, 'dhcp.ranges');
|
||||||
|
assertEq(miss.dirty, false, 'clean flag');
|
||||||
|
assertEq(miss.class, '', 'clean class');
|
||||||
|
assertEq(miss.title, '', 'clean title');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── orphanInfo (removed dict keys) ──────────────────────────── */
|
||||||
|
|
||||||
|
test('orphanInfo flags a removed peer with no live row', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
|
||||||
|
const info = orphanInfo(set, 'peers', ['peers.p2', 'peers.p3']);
|
||||||
|
assertEq(info.dirty, true, 'orphan is dirty');
|
||||||
|
assertEq(info.class, 'config-dirty', 'orphan class');
|
||||||
|
assertEq(info.title, 'Unapplied changes: peers.p1', 'orphan tooltip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orphanInfo is clean when the pending path still has a live row', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'peers.p1.endpoint', action: 'changed' }] });
|
||||||
|
assertEq(orphanInfo(set, 'peers', ['peers.p1', 'peers.p2']).dirty, false, 'matched child is not an orphan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orphanInfo flags a removed peer when no peers remain', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
|
||||||
|
assertEq(orphanInfo(set, 'peers', []).dirty, true, 'no children means the orphan stands');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orphanInfo ignores pending paths outside the root', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
|
||||||
|
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'unrelated root');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orphanInfo is clean when the root itself is pending', () => {
|
||||||
|
// A whole-dict `peers` change marks every child row instead; the
|
||||||
|
// container-level marker would be redundant.
|
||||||
|
const set = dirtySet({ pending_diff: [{ path: 'peers', action: 'changed' }] });
|
||||||
|
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'root-pending is not an orphan');
|
||||||
|
assert(isDirty(set, 'peers.p1'), 'but the rows are still marked');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orphanInfo is clean for an empty set or the never-applied sentinel', () => {
|
||||||
|
assertEq(orphanInfo(new Set(), 'peers', []).dirty, false, 'empty set');
|
||||||
|
const sentinel = dirtySet({ pending_changes: true, pending_diff: [] });
|
||||||
|
assertEq(orphanInfo(sentinel, 'peers', []).dirty, false, 'sentinel: element markers already cover it');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orphanInfo lists multiple orphans sorted', () => {
|
||||||
|
const set = dirtySet({ pending_diff: [
|
||||||
|
{ path: 'peers.b', action: 'removed' },
|
||||||
|
{ path: 'peers.a', action: 'removed' },
|
||||||
|
{ path: 'peers.c.field', action: 'changed' },
|
||||||
|
]});
|
||||||
|
const info = orphanInfo(set, 'peers', ['peers.c']);
|
||||||
|
assertEq(info.title, 'Unapplied changes: peers.a, peers.b', 'only the orphans, sorted');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── firewall zone + type granularity ────────────────────────── */
|
||||||
|
|
||||||
|
test('fwDirty builds a zone-to-types map', () => {
|
||||||
|
const m = fwDirty({
|
||||||
|
pending: [
|
||||||
|
{ zone: 'public', type: 'services' },
|
||||||
|
{ zone: 'public', type: 'rich_rules' },
|
||||||
|
{ zone: 'dmz', type: 'interfaces' },
|
||||||
|
{ zone: null },
|
||||||
|
{ zone: 'lan' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assertEq(m.size, 3, 'three zones (null-zone entry skipped, typeless zone kept)');
|
||||||
|
assert(m.get('public').has('services'), 'public services');
|
||||||
|
assert(m.get('public').has('rich_rules'), 'public rich_rules');
|
||||||
|
assert(m.get('dmz').has('interfaces'), 'dmz interfaces');
|
||||||
|
assert(m.get('lan').size === 0, 'typeless zone has an empty type set');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fwIsDirty by zone and by zone+type', () => {
|
||||||
|
const m = fwDirty({ pending: [{ zone: 'public', type: 'services' }] });
|
||||||
|
assert(fwIsDirty(m, 'public'), 'zone-only match');
|
||||||
|
assert(fwIsDirty(m, 'public', 'services'), 'zone+type match');
|
||||||
|
assert(!fwIsDirty(m, 'public', 'rich_rules'), 'wrong type');
|
||||||
|
assert(!fwIsDirty(m, 'dmz'), 'unknown zone');
|
||||||
|
assert(!fwIsDirty(new Map(), 'public'), 'empty map');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fwInfo and fwTitle carry the pending types', () => {
|
||||||
|
const m = fwDirty({
|
||||||
|
pending: [
|
||||||
|
{ zone: 'public', type: 'rich_rules' },
|
||||||
|
{ zone: 'public', type: 'services' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const zone = fwInfo(m, 'public');
|
||||||
|
assertEq(zone.dirty, true, 'zone dirty');
|
||||||
|
assertEq(zone.class, 'config-dirty', 'zone class');
|
||||||
|
assertEq(zone.title, 'Unapplied changes: rich_rules, services', 'zone tooltip lists all types');
|
||||||
|
const typed = fwInfo(m, 'public', 'services');
|
||||||
|
assertEq(typed.title, 'Unapplied changes: services', 'typed tooltip lists only that type');
|
||||||
|
assertEq(fwInfo(m, 'dmz').dirty, false, 'unknown zone clean');
|
||||||
|
assertEq(fwTitle(m, 'nope'), '', 'no tooltip for unknown zone');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Runner ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
passed++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` \u2717 ${name}: ${e.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
|
})();
|
||||||
@@ -58,6 +58,25 @@ 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 <file> must trail the subcommand args: acme.sh would
|
||||||
|
# otherwise consume the first subcommand arg as its file argument.
|
||||||
|
# The explicit file path (not a bare trailing --log) is required
|
||||||
|
# because a valueless trailing --log makes acme.sh's arg loop
|
||||||
|
# double-shift under dash and fail with "shift: can't shift that
|
||||||
|
# many".
|
||||||
|
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[-2] == "--log"
|
||||||
|
assert cmd[-1].endswith("acme.sh.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
|
||||||
|
|||||||
+310
-83
@@ -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),
|
||||||
@@ -1099,6 +1235,97 @@ class TestDaemonConfigApplyStamp:
|
|||||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
|
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
|
||||||
|
|
||||||
|
|
||||||
|
class TestDaemonMutatorBaselineStamp:
|
||||||
|
"""Per-zone mutations apply to live firewalld immediately and must
|
||||||
|
re-stamp the applied baseline, so cancel-all reverts to the post-mutation
|
||||||
|
state instead of an older snapshot (regression: stale install-era
|
||||||
|
snapshot resurrected a phantom 'remove interface' pending change).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ZONES_OUT = "public\ninternal"
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._reload")
|
||||||
|
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
|
||||||
|
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
|
||||||
|
def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
|
||||||
|
with (
|
||||||
|
patch.object(daemonfirewall, "_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=[])
|
||||||
|
daemonfirewall.set_zone_interfaces(
|
||||||
|
None, {"zone": "internal", "interfaces": ["eth1"]}
|
||||||
|
)
|
||||||
|
saved = mock_save.call_args[0][0]
|
||||||
|
assert saved["zones"]["internal"]["interfaces"] == ["eth1"]
|
||||||
|
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
|
||||||
|
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
|
||||||
|
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["interfaces"] == [
|
||||||
|
"eth1"
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._reload")
|
||||||
|
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
|
||||||
|
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
|
||||||
|
def test_set_zone_services_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
||||||
|
),
|
||||||
|
patch.object(daemonfirewall, "_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=[])
|
||||||
|
daemonfirewall.set_zone_services(
|
||||||
|
None, {"zone": "internal", "services": ["ssh"]}
|
||||||
|
)
|
||||||
|
saved = mock_save.call_args[0][0]
|
||||||
|
assert saved["zones"]["internal"]["services"] == ["ssh"]
|
||||||
|
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["services"] == [
|
||||||
|
"ssh"
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._reload")
|
||||||
|
@patch(
|
||||||
|
"daemon.handlers.firewall._get_config",
|
||||||
|
return_value={"zones": {"internal": {}}},
|
||||||
|
)
|
||||||
|
@patch("daemon.handlers.firewall.run")
|
||||||
|
def test_set_masquerade_syncs_config_and_stamps(
|
||||||
|
self, mock_run, mock_cfg, mock_reload
|
||||||
|
):
|
||||||
|
with (
|
||||||
|
patch.object(daemonfirewall, "_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=[])
|
||||||
|
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
|
||||||
|
saved = mock_save.call_args[0][0]
|
||||||
|
assert saved["zones"]["internal"]["masquerade"] is True
|
||||||
|
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
|
||||||
|
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._reload")
|
||||||
|
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
|
||||||
|
@patch("daemon.handlers.firewall.run")
|
||||||
|
def test_set_masquerade_no_config_entry_skips_write(
|
||||||
|
self, mock_run, mock_cfg, mock_reload
|
||||||
|
):
|
||||||
|
"""A zone absent from the config must not gain a bare entry — that
|
||||||
|
would manufacture spurious service diffs on the next poll."""
|
||||||
|
with (
|
||||||
|
patch.object(daemonfirewall, "_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=[])
|
||||||
|
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
|
||||||
|
mock_save.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestDaemonGetConfigEndpoint:
|
class TestDaemonGetConfigEndpoint:
|
||||||
def test_strips_apply_meta(self):
|
def test_strips_apply_meta(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
@@ -1117,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()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
|
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -1303,3 +1304,74 @@ class TestGetRenewStatus:
|
|||||||
assert status["domain"] == "example.com"
|
assert status["domain"] == "example.com"
|
||||||
assert status["status"] == "completed"
|
assert status["status"] == "completed"
|
||||||
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
|
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeAcmeHome:
|
||||||
|
def test_normalize_invokes_sudo_chmod_on_files(self, tmp_path):
|
||||||
|
f1 = tmp_path / "account.conf"
|
||||||
|
f1.write_text("x")
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
f2 = tmp_path / "sub" / "dom.key"
|
||||||
|
f2.write_text("x")
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
|
||||||
|
patch(
|
||||||
|
"lib.common.run_proc",
|
||||||
|
return_value=MagicMock(returncode=0, stderr=""),
|
||||||
|
) as mock_proc,
|
||||||
|
):
|
||||||
|
acme_mod.normalize_acme_home()
|
||||||
|
args = mock_proc.call_args.args[0]
|
||||||
|
assert args[:2] == ["chmod", "g+rwX"]
|
||||||
|
assert set(args[2:]) == {str(f1), str(f2)}
|
||||||
|
mock_proc.assert_called_once()
|
||||||
|
|
||||||
|
def test_normalize_empty_tree_skips_sudo(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
|
||||||
|
patch("lib.common.run_proc") as mock_proc,
|
||||||
|
):
|
||||||
|
acme_mod.normalize_acme_home()
|
||||||
|
mock_proc.assert_not_called()
|
||||||
|
|
||||||
|
def test_normalize_failure_does_not_raise(self, tmp_path):
|
||||||
|
(tmp_path / "a.conf").write_text("x")
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
|
||||||
|
patch(
|
||||||
|
"lib.common.run_proc",
|
||||||
|
return_value=MagicMock(returncode=1, stderr="denied"),
|
||||||
|
),
|
||||||
|
patch.object(acme_mod, "logger"),
|
||||||
|
):
|
||||||
|
acme_mod.normalize_acme_home()
|
||||||
|
|
||||||
|
def test_preflight_normalizes_before_run(self):
|
||||||
|
calls = []
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
acme_mod,
|
||||||
|
"normalize_acme_home",
|
||||||
|
side_effect=lambda: calls.append("normalize"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
acme_mod,
|
||||||
|
"_run_acme",
|
||||||
|
side_effect=lambda args: calls.append("run:" + " ".join(args)) or "ok",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
out = acme_mod._run_acme_preflight(["--list", "--listraw"])
|
||||||
|
assert calls == ["normalize", "run:--list --listraw"]
|
||||||
|
assert out == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreflightWiring:
|
||||||
|
def test_issue_uses_preflight(self):
|
||||||
|
source = inspect.getsource(acme_mod._run_issue)
|
||||||
|
assert "_run_acme_preflight" in source
|
||||||
|
assert "normalize_acme_home" in source
|
||||||
|
|
||||||
|
def test_renew_uses_preflight(self):
|
||||||
|
source = inspect.getsource(acme_mod._run_renew)
|
||||||
|
assert "_run_acme_preflight" in source
|
||||||
|
assert "normalize_acme_home" in source
|
||||||
|
|||||||
@@ -15,18 +15,36 @@ from daemon.handlers.network import (
|
|||||||
save_interface,
|
save_interface,
|
||||||
set_sysctl,
|
set_sysctl,
|
||||||
)
|
)
|
||||||
|
from lib import dnsmasq as _dm
|
||||||
|
from lib import firewall as _fw
|
||||||
from lib import network as _net
|
from lib import network as _net
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tmp_network(tmp_path):
|
def tmp_network(tmp_path):
|
||||||
orig_config = _net.CONFIG_FILE
|
# Handler endpoints emit "networkd" sync events; the subscribers
|
||||||
orig_data = _net.DATA_DIR
|
# (lib.sync.NetworkToAllSync) read/write the firewall and dnsmasq
|
||||||
|
# configs, and apply_all re-stamps the dnsmasq config. Point all of
|
||||||
|
# those paths at tmp so tests never touch the real config files.
|
||||||
|
orig_net = (_net.CONFIG_FILE, _net.DATA_DIR)
|
||||||
|
orig_dm = (_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR)
|
||||||
|
orig_fw = (_fw.CONFIG_DIR, _fw.CONFIG_FILE)
|
||||||
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
|
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
|
||||||
_net.DATA_DIR = tmp_path / "data" / "networkd"
|
_net.DATA_DIR = tmp_path / "data" / "networkd"
|
||||||
|
_dm.CONFIG_DIR = tmp_path / "config" / "dnsmasq"
|
||||||
|
_dm.DATA_DIR = tmp_path / "data" / "dnsmasq"
|
||||||
|
_dm.CONFIG_PATH = _dm.CONFIG_DIR / "config.json"
|
||||||
|
_dm.FRAGMENTS_DIR = _dm.DATA_DIR / "fragments"
|
||||||
|
_dm.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_dm.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_dm.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_fw.CONFIG_DIR = tmp_path / "config" / "firewall"
|
||||||
|
_fw.CONFIG_FILE = _fw.CONFIG_DIR / "config.json"
|
||||||
|
_fw.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
yield tmp_path
|
yield tmp_path
|
||||||
_net.CONFIG_FILE = orig_config
|
_net.CONFIG_FILE, _net.DATA_DIR = orig_net
|
||||||
_net.DATA_DIR = orig_data
|
_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR = orig_dm
|
||||||
|
_fw.CONFIG_DIR, _fw.CONFIG_FILE = orig_fw
|
||||||
|
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
@@ -363,7 +381,7 @@ class TestInferEndpoints:
|
|||||||
|
|
||||||
|
|
||||||
class TestSetSysctl:
|
class TestSetSysctl:
|
||||||
def test_set_sysctl_success(self):
|
def test_set_sysctl_success(self, tmp_network):
|
||||||
with (
|
with (
|
||||||
patch("daemon.handlers.network.run") as mock_run,
|
patch("daemon.handlers.network.run") as mock_run,
|
||||||
patch.object(Path, "read_text", return_value="1"),
|
patch.object(Path, "read_text", return_value="1"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+81
-1
@@ -55,6 +55,44 @@ class TestGetConfig:
|
|||||||
assert "ssl" in cfg
|
assert "ssl" in cfg
|
||||||
assert cfg["domains"] == {}
|
assert cfg["domains"] == {}
|
||||||
|
|
||||||
|
def test_read_does_not_rewrite_unchanged_file(self, temp_data_dir):
|
||||||
|
"""get_config() must not re-save a file that needs no migration."""
|
||||||
|
nginx.save_config(
|
||||||
|
{
|
||||||
|
"backends": {"webui": {"_migrated": True, "paths": {}}},
|
||||||
|
"domains": {"app.example.com": {"backend": "webui"}},
|
||||||
|
"ssl": {"protocols": "TLSv1.3"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||||
|
cfg = nginx.get_config()
|
||||||
|
assert cfg["domains"] == {"app.example.com": {"backend": "webui"}}
|
||||||
|
# No churn: reading a current-format config leaves the file alone.
|
||||||
|
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
|
||||||
|
|
||||||
|
def test_read_migrates_in_memory_without_writing(self, temp_data_dir):
|
||||||
|
"""get_config() is pure: migration is applied in memory, file untouched."""
|
||||||
|
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
||||||
|
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||||
|
cfg = nginx.get_config()
|
||||||
|
# Migration added the builtin webui backend (in memory only).
|
||||||
|
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
|
||||||
|
# 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:
|
||||||
def test_saves_and_reloads(self, temp_data_dir):
|
def test_saves_and_reloads(self, temp_data_dir):
|
||||||
@@ -266,9 +304,51 @@ class TestGenerateServerConf:
|
|||||||
}
|
}
|
||||||
out = nginx.generate_server_conf(cfg)
|
out = nginx.generate_server_conf(cfg)
|
||||||
assert "proxy_pass http://127.0.0.1:9090;" in out
|
assert "proxy_pass http://127.0.0.1:9090;" in out
|
||||||
assert "add_header X-Content-Type-Options" not in out
|
# Server-level security headers come from Flask, not nginx
|
||||||
|
assert "Strict-Transport-Security" not in out
|
||||||
|
assert "Referrer-Policy" not in out
|
||||||
assert "wall_mgmt_access.log" in out
|
assert "wall_mgmt_access.log" in out
|
||||||
|
|
||||||
|
def test_management_static_location(self, temp_data_dir):
|
||||||
|
cfg = {
|
||||||
|
"domain": "mgmt.example.com",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
|
||||||
|
"is_management": True,
|
||||||
|
},
|
||||||
|
"/ws": {
|
||||||
|
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||||
|
"is_websocket": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"force_ssl": True,
|
||||||
|
"cert": "acme",
|
||||||
|
}
|
||||||
|
out = nginx.generate_server_conf(cfg)
|
||||||
|
static_root = str(nginx.PROJECT_DIR / "webui" / "static")
|
||||||
|
assert "location /static/ {" in out
|
||||||
|
assert f"alias {static_root}/;" in out
|
||||||
|
assert 'add_header Cache-Control "no-cache" always;' in out
|
||||||
|
assert "add_header X-Content-Type-Options nosniff always;" in out
|
||||||
|
assert (
|
||||||
|
"add_header Content-Security-Policy \"default-src 'none'\" always;" in out
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_static_location_only_for_management_root(self, temp_data_dir):
|
||||||
|
cfg = {
|
||||||
|
"domain": "app.example.com",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force_ssl": True,
|
||||||
|
"cert": "acme",
|
||||||
|
}
|
||||||
|
out = nginx.generate_server_conf(cfg)
|
||||||
|
assert "location /static/" not in out
|
||||||
|
|
||||||
def test_websocket_path(self, temp_data_dir):
|
def test_websocket_path(self, temp_data_dir):
|
||||||
cfg = {
|
cfg = {
|
||||||
"domain": "mgmt.example.com",
|
"domain": "mgmt.example.com",
|
||||||
|
|||||||
+26
-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,37 +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
|
||||||
|
|
||||||
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__:
|
||||||
@@ -134,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"]
|
||||||
@@ -147,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}"
|
||||||
|
|||||||
+62
-12
@@ -3,6 +3,9 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import daemon.collectors.acme
|
||||||
|
import daemon.collectors.dnsmasq
|
||||||
|
import daemon.collectors.firewall
|
||||||
from lib.state import State, state
|
from lib.state import State, state
|
||||||
|
|
||||||
|
|
||||||
@@ -50,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:
|
||||||
@@ -87,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:
|
||||||
@@ -143,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:
|
||||||
@@ -167,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()
|
||||||
@@ -180,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 = {
|
||||||
@@ -225,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)
|
||||||
|
|
||||||
@@ -249,6 +254,51 @@ class TestCollectFailure:
|
|||||||
assert s.is_populated() is False
|
assert s.is_populated() is False
|
||||||
|
|
||||||
|
|
||||||
|
_ACCOUNT = {"registered": False, "email": "", "ca": ""}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAcmeCollectNonFatal:
|
||||||
|
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
|
||||||
|
|
||||||
|
def test_list_failure_yields_empty_certs_and_error(self):
|
||||||
|
from daemon.collectors.acme import _collect_acme
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"lib.acme.list_certs",
|
||||||
|
side_effect=RuntimeError("acme.sh failed with exit code 2"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = _collect_acme()
|
||||||
|
|
||||||
|
assert result["certs"] == []
|
||||||
|
assert result["email"] == "a@b.c"
|
||||||
|
assert result["status"]["error"] is not None
|
||||||
|
assert "exit code 2" in result["status"]["error"]
|
||||||
|
|
||||||
|
def test_success_reports_no_error(self):
|
||||||
|
from daemon.collectors.acme import _collect_acme
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||||
|
),
|
||||||
|
patch("lib.acme.list_certs", return_value=[]),
|
||||||
|
patch.object(
|
||||||
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = _collect_acme()
|
||||||
|
|
||||||
|
assert result["status"] == {"error": None}
|
||||||
|
|
||||||
|
|
||||||
class TestStateVersions:
|
class TestStateVersions:
|
||||||
def test_version_starts_at_zero(self):
|
def test_version_starts_at_zero(self):
|
||||||
s = State()
|
s = State()
|
||||||
|
|||||||
@@ -377,6 +377,45 @@ class TestStatusApplyAll:
|
|||||||
assert "Firewall" in result["errors"]
|
assert "Firewall" in result["errors"]
|
||||||
mock_nginx.assert_called_once()
|
mock_nginx.assert_called_once()
|
||||||
|
|
||||||
|
def test_force_body_forwarded_to_firewall_only(self):
|
||||||
|
mock_fw = MagicMock()
|
||||||
|
mock_nginx = MagicMock()
|
||||||
|
|
||||||
|
pending_data = {**self._fake_pending_all}
|
||||||
|
pending_data["firewall"]["needs_apply"] = True
|
||||||
|
pending_data["firewall"]["change_count"] = 1
|
||||||
|
pending_data["nginx"]["pending_changes"] = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||||
|
patch("daemon.handlers.status.refresh_state"),
|
||||||
|
patch.dict(
|
||||||
|
"daemon.handlers.status.SYS_APPLY",
|
||||||
|
{
|
||||||
|
"firewall": mock_fw,
|
||||||
|
"nginx": mock_nginx,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
status.status_apply_all(None, {"force": True})
|
||||||
|
mock_fw.assert_called_once_with(None, {"force": True})
|
||||||
|
mock_nginx.assert_called_once_with(None, None)
|
||||||
|
|
||||||
|
def test_no_body_passed_without_force(self):
|
||||||
|
mock_fw = MagicMock()
|
||||||
|
|
||||||
|
pending_data = {**self._fake_pending_all}
|
||||||
|
pending_data["firewall"]["needs_apply"] = True
|
||||||
|
pending_data["firewall"]["change_count"] = 1
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||||
|
patch("daemon.handlers.status.refresh_state"),
|
||||||
|
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
|
||||||
|
):
|
||||||
|
status.status_apply_all(None, None)
|
||||||
|
mock_fw.assert_called_once_with(None, None)
|
||||||
|
|
||||||
|
|
||||||
class TestSysOrder:
|
class TestSysOrder:
|
||||||
"""Verify SYS_ORDER and SYS_LABELS constants."""
|
"""Verify SYS_ORDER and SYS_LABELS constants."""
|
||||||
|
|||||||
+10
-2
@@ -226,10 +226,14 @@ class TestGetAffected:
|
|||||||
|
|
||||||
|
|
||||||
class TestDnsToFirewallSync:
|
class TestDnsToFirewallSync:
|
||||||
|
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
|
||||||
|
@patch("lib.dnsmasq.save_config")
|
||||||
@patch("lib.firewall.save_config")
|
@patch("lib.firewall.save_config")
|
||||||
@patch("lib.firewall.get_config")
|
@patch("lib.firewall.get_config")
|
||||||
@patch("lib.dnsmasq.get_config")
|
@patch("lib.dnsmasq.get_config")
|
||||||
def test_adds_dhcp_dns(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
def test_adds_dhcp_dns(
|
||||||
|
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
|
||||||
|
):
|
||||||
mock_dm_get.return_value = {
|
mock_dm_get.return_value = {
|
||||||
"dhcp": {
|
"dhcp": {
|
||||||
"ranges": [
|
"ranges": [
|
||||||
@@ -288,10 +292,14 @@ class TestDnsToFirewallSync:
|
|||||||
assert "dhcp" not in saved_cfg["zones"]["internal"]["services"]
|
assert "dhcp" not in saved_cfg["zones"]["internal"]["services"]
|
||||||
assert "dns" not in saved_cfg["zones"]["internal"]["services"]
|
assert "dns" not in saved_cfg["zones"]["internal"]["services"]
|
||||||
|
|
||||||
|
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
|
||||||
|
@patch("lib.dnsmasq.save_config")
|
||||||
@patch("lib.firewall.save_config")
|
@patch("lib.firewall.save_config")
|
||||||
@patch("lib.firewall.get_config")
|
@patch("lib.firewall.get_config")
|
||||||
@patch("lib.dnsmasq.get_config")
|
@patch("lib.dnsmasq.get_config")
|
||||||
def test_idempotent(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
def test_idempotent(
|
||||||
|
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
|
||||||
|
):
|
||||||
mock_dm_get.return_value = {
|
mock_dm_get.return_value = {
|
||||||
"dhcp": {
|
"dhcp": {
|
||||||
"ranges": [
|
"ranges": [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lib import system_import
|
from lib import system_import
|
||||||
from lib.common import save_json
|
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY, config_hash, save_json
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -137,6 +137,39 @@ class TestImportDnsmasq:
|
|||||||
):
|
):
|
||||||
assert not system_import.import_dnsmasq()
|
assert not system_import.import_dnsmasq()
|
||||||
|
|
||||||
|
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
|
||||||
|
# Existing config differs from the live conf and carries apply
|
||||||
|
# bookkeeping — the rewrite must keep the baseline so pending
|
||||||
|
# detection and cancel-all survive daemon restarts.
|
||||||
|
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||||
|
self._write_conf(tmp_path, conf)
|
||||||
|
cfg_path = tmp_path / "config" / "dnsmasq"
|
||||||
|
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
baseline = {"dns": {"upstreams": ["1.1.1.1"]}}
|
||||||
|
save_json(
|
||||||
|
cfg_path / "config.json",
|
||||||
|
{
|
||||||
|
**baseline,
|
||||||
|
_APPLY_HASH_KEY: "old-hash",
|
||||||
|
_LAST_APPLIED_CONFIG_KEY: baseline,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert system_import.import_dnsmasq()
|
||||||
|
cfg = self._read_json(tmp_path)
|
||||||
|
assert cfg[_APPLY_HASH_KEY] == "old-hash"
|
||||||
|
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
|
||||||
|
assert cfg["dns"]["upstreams"] == ["8.8.8.8"]
|
||||||
|
|
||||||
|
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
|
||||||
|
# No config file yet: the imported content is the running state,
|
||||||
|
# so it must be stamped as applied (no phantom pending changes).
|
||||||
|
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||||
|
self._write_conf(tmp_path, conf)
|
||||||
|
assert system_import.import_dnsmasq()
|
||||||
|
cfg = self._read_json(tmp_path)
|
||||||
|
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||||
|
assert cfg[_LAST_APPLIED_CONFIG_KEY]["dns"]["upstreams"] == ["8.8.8.8"]
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
# WireGuard
|
# WireGuard
|
||||||
@@ -232,6 +265,44 @@ class TestImportWireguard:
|
|||||||
assert system_import.import_wireguard()
|
assert system_import.import_wireguard()
|
||||||
assert not system_import.import_wireguard()
|
assert not system_import.import_wireguard()
|
||||||
|
|
||||||
|
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
|
||||||
|
conf = (
|
||||||
|
"[Interface]\n"
|
||||||
|
" PrivateKey = abc123\n"
|
||||||
|
" Address = 10.137.0.1/24\n"
|
||||||
|
" ListenPort = 51820\n"
|
||||||
|
)
|
||||||
|
self._write_conf(tmp_path, conf)
|
||||||
|
cfg_path = tmp_path / "config" / "wireguard"
|
||||||
|
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
baseline = {"interface": {"listen_port": 51821}, "peers": {}}
|
||||||
|
save_json(
|
||||||
|
cfg_path / "config.json",
|
||||||
|
{
|
||||||
|
**baseline,
|
||||||
|
_APPLY_HASH_KEY: "old-hash",
|
||||||
|
_LAST_APPLIED_CONFIG_KEY: baseline,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert system_import.import_wireguard()
|
||||||
|
cfg = self._read_json(tmp_path)
|
||||||
|
assert cfg[_APPLY_HASH_KEY] == "old-hash"
|
||||||
|
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
|
||||||
|
assert cfg["interface"]["listen_port"] == 51820
|
||||||
|
|
||||||
|
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
|
||||||
|
conf = (
|
||||||
|
"[Interface]\n"
|
||||||
|
" PrivateKey = abc123\n"
|
||||||
|
" Address = 10.137.0.1/24\n"
|
||||||
|
" ListenPort = 51820\n"
|
||||||
|
)
|
||||||
|
self._write_conf(tmp_path, conf)
|
||||||
|
assert system_import.import_wireguard()
|
||||||
|
cfg = self._read_json(tmp_path)
|
||||||
|
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||||
|
assert cfg[_LAST_APPLIED_CONFIG_KEY]["interface"]["private_key"] == "abc123"
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
# Networkd
|
# Networkd
|
||||||
@@ -593,6 +664,18 @@ class TestImportFirewall:
|
|||||||
cfg = self._read_json(tmp_path)
|
cfg = self._read_json(tmp_path)
|
||||||
assert "dmz" not in cfg["zones"]
|
assert "dmz" not in cfg["zones"]
|
||||||
|
|
||||||
|
def test_import_stamps_applied(self, temp_project, tmp_path):
|
||||||
|
# Fresh import adopts the live firewalld state, which is by
|
||||||
|
# definition the applied state — the file must carry a baseline.
|
||||||
|
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
|
||||||
|
assert system_import.import_firewall()
|
||||||
|
cfg = self._read_json(tmp_path)
|
||||||
|
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||||
|
assert cfg[_LAST_APPLIED_CONFIG_KEY]["zones"]["public"]["interfaces"] == [
|
||||||
|
"eth0",
|
||||||
|
"eth1",
|
||||||
|
]
|
||||||
|
|
||||||
def test_parse_error_returns_false(self, temp_project, tmp_path):
|
def test_parse_error_returns_false(self, temp_project, tmp_path):
|
||||||
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
|
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
|
||||||
assert not system_import.import_firewall()
|
assert not system_import.import_firewall()
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+137
-270
@@ -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]:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
iface = (body.get("interface") or "").strip() or None
|
||||||
|
start = (body.get("start") or "").strip()
|
||||||
|
end = (body.get("end") or "").strip()
|
||||||
|
if not start or not end:
|
||||||
|
raise ValueError("'start' and 'end' are required")
|
||||||
|
return {
|
||||||
|
"interface": iface or "",
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"lease_time": body.get("lease_time", "12h"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_range_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
iface = (body.get("interface") or "").strip() or ""
|
||||||
|
start = (body.get("start") or "").strip()
|
||||||
|
end = (body.get("end") or "").strip()
|
||||||
|
if not start or not end:
|
||||||
|
raise ValueError("'start' and 'end' are required")
|
||||||
|
return {"interface": iface, "start": start, "end": end}
|
||||||
|
|
||||||
|
|
||||||
|
@daemon_route(
|
||||||
|
POST_DNSMASQ_RANGES_ADD,
|
||||||
|
bp,
|
||||||
|
rule="/ranges",
|
||||||
|
body=_add_range_body,
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
def add_range_bp():
|
def add_range_bp():
|
||||||
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface.
|
"""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 {}
|
|
||||||
iface = body.get("interface", "").strip() or None
|
|
||||||
start = body.get("start", "").strip()
|
|
||||||
end = body.get("end", "").strip()
|
|
||||||
lease_time = body.get("lease_time", "12h")
|
|
||||||
if not start or not end:
|
|
||||||
return _error("'start' and 'end' are required", 400)
|
|
||||||
try:
|
|
||||||
post(
|
|
||||||
POST_DNSMASQ_RANGES_ADD,
|
|
||||||
{
|
|
||||||
"interface": iface or "",
|
|
||||||
"start": start,
|
|
||||||
"end": end,
|
|
||||||
"lease_time": lease_time,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
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"])
|
@daemon_route(
|
||||||
|
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||||
|
bp,
|
||||||
|
rule="/ranges",
|
||||||
|
body=_remove_range_body,
|
||||||
|
transform=void_transform,
|
||||||
|
)
|
||||||
def remove_range_bp():
|
def remove_range_bp():
|
||||||
"""DELETE /api/dhcp/ranges — Remove a DHCP address range.
|
"""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 {}
|
|
||||||
iface = body.get("interface", "").strip() or ""
|
|
||||||
start = body.get("start", "").strip()
|
|
||||||
end = body.get("end", "").strip()
|
|
||||||
if not start or not end:
|
|
||||||
return _error("'start' and 'end' are required", 400)
|
|
||||||
try:
|
|
||||||
delete(
|
|
||||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
|
||||||
{"interface": iface, "start": start, "end": end},
|
|
||||||
)
|
|
||||||
logger.info("DHCP range removed via API: %s-%s", start, end)
|
|
||||||
return _ok(None)
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info("Remove DHCP range not found: %s", exc)
|
|
||||||
return _error(str(exc), 404)
|
|
||||||
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(
|
|
||||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
|
||||||
{"name": name, "address": address, "hostname": hostname},
|
def _dns_record_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||||
)
|
return {
|
||||||
logger.info("DNS record added via API: %s -> %s", name, address)
|
"name": sent["name"],
|
||||||
return _ok({"name": name, "address": address, "hostname": hostname})
|
"address": sent["address"],
|
||||||
except BadRequest as exc:
|
"hostname": sent["hostname"],
|
||||||
logger.info("Add DNS record rejected: %s", exc)
|
}
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to add DNS record: %s", exc)
|
@daemon_route(
|
||||||
return _error(str(exc), 500)
|
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||||
|
bp,
|
||||||
|
rule="/dns-record",
|
||||||
|
body=_add_dns_record_body,
|
||||||
|
transform=_dns_record_echo,
|
||||||
|
)
|
||||||
|
def add_dns_record_bp():
|
||||||
|
"""POST /api/dhcp/dns-record — Add a DNS record."""
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
|
||||||
|
|||||||
+249
-524
@@ -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(
|
||||||
|
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||||
|
bp,
|
||||||
|
rule="/forward-port",
|
||||||
|
body=_add_forward_port_body,
|
||||||
|
transform=_forward_port_add_echo,
|
||||||
|
)
|
||||||
def add_forward_port_bp():
|
def add_forward_port_bp():
|
||||||
"""Add a port forwarding rule to a firewall zone.
|
"""POST /api/firewall/forward-port — Add a port forwarding rule to a 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,
|
|
||||||
{
|
|
||||||
"zone": zone,
|
|
||||||
"port": port_int,
|
|
||||||
"proto": proto,
|
|
||||||
"toaddr": toaddr_str,
|
|
||||||
"toport": toport_int,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
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):
|
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||||
"""Remove a port forwarding rule from a firewall zone.
|
bp,
|
||||||
|
rule="/forward-port/<zone>/<int:port>/<proto>",
|
||||||
Endpoint:
|
transform=_forward_port_remove_echo,
|
||||||
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
|
)
|
||||||
|
def remove_forward_port_bp():
|
||||||
Args:
|
"""DELETE /api/firewall/forward-port/<zone>/<port>/<proto> — Remove a rule."""
|
||||||
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,
|
|
||||||
{"zone": zone, "port": port, "proto": proto},
|
|
||||||
)
|
|
||||||
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
|
|
||||||
return _ok({"zone": zone, "port": port, "proto": proto})
|
|
||||||
except NotFound as exc:
|
|
||||||
logger.info(
|
|
||||||
"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
-79
@@ -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,93 +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
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with applied subsystems list and any errors encountered.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _ok(post(POST_STATUS_APPLY_ALL))
|
|
||||||
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)
|
|
||||||
|
|||||||
+215
-436
@@ -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,473 +35,247 @@ 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):
|
if "interface" in body:
|
||||||
return _error("Request body must be a JSON object", 400)
|
body = dict(body)
|
||||||
try:
|
body["interface"] = dict(body["interface"])
|
||||||
if "interface" in body:
|
body["interface"].pop("private_key", None)
|
||||||
body = dict(body)
|
return body
|
||||||
body["interface"] = dict(body["interface"])
|
|
||||||
body["interface"].pop("private_key", None)
|
|
||||||
post(POST_WIREGUARD_CONFIG, 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(
|
"name": name,
|
||||||
POST_WIREGUARD_PEERS_ADD,
|
"endpoint": body.get("endpoint"),
|
||||||
{
|
"allowed_ips": body.get("allowed_ips", []),
|
||||||
"name": name,
|
"persistent_keepalive": body.get("persistent_keepalive"),
|
||||||
"endpoint": body.get("endpoint"),
|
"preshared_key": body.get("preshared_key"),
|
||||||
"allowed_ips": body.get("allowed_ips", []),
|
"description": body.get("description"),
|
||||||
"persistent_keepalive": body.get("persistent_keepalive"),
|
"access_class": body.get("access_class"),
|
||||||
"preshared_key": body.get("preshared_key"),
|
}
|
||||||
"description": body.get("description"),
|
|
||||||
"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)
|
|
||||||
|
|||||||
@@ -107,19 +107,53 @@ export const _toasts = [];
|
|||||||
const _toastIds = { next: 1 };
|
const _toastIds = { next: 1 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a toast notification. Auto-dismisses after `duration` ms.
|
* Default auto-dismiss durations per toast type (ms). 0 = never
|
||||||
|
* auto-dismiss. Errors stay on screen until dismissed so long
|
||||||
|
* failure messages remain readable.
|
||||||
|
*/
|
||||||
|
const _TOAST_DEFAULT_DURATIONS = {
|
||||||
|
info: 4000,
|
||||||
|
success: 4000,
|
||||||
|
warning: 8000,
|
||||||
|
error: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-dismiss timer that pauses while the toast is hovered.
|
||||||
|
* Re-checks in 1s while hovered instead of dismissing.
|
||||||
|
*/
|
||||||
|
function _scheduleToastDismiss(id, delay) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const t = _toasts.find(t => t.id === id);
|
||||||
|
if (!t) return;
|
||||||
|
if (t.hovered) _scheduleToastDismiss(id, 1000);
|
||||||
|
else dismissToast(id);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast notification.
|
||||||
|
*
|
||||||
|
* When `duration` is omitted, per-type defaults apply: 'info' and
|
||||||
|
* 'success' auto-dismiss after 4000 ms, 'warning' after 8000 ms, and
|
||||||
|
* 'error' toasts never auto-dismiss. An explicit `duration` overrides
|
||||||
|
* the default. The auto-dismiss timer pauses while the toast is
|
||||||
|
* hovered.
|
||||||
*
|
*
|
||||||
* @param {string} message – Toast text
|
* @param {string} message – Toast text
|
||||||
* @param {string} [type] – 'info' | 'success' | 'error' | 'warning'
|
* @param {string} [type] – 'info' | 'success' | 'error' | 'warning'
|
||||||
* @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite)
|
* @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite)
|
||||||
* @returns {number} id
|
* @returns {number} id
|
||||||
*/
|
*/
|
||||||
export function toast(message, type = 'info', duration = 4000) {
|
export function toast(message, type = 'info', duration) {
|
||||||
const id = _toastIds.next++;
|
const id = _toastIds.next++;
|
||||||
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
|
const dur = duration === undefined
|
||||||
|
? (_TOAST_DEFAULT_DURATIONS[type] ?? 4000)
|
||||||
|
: duration;
|
||||||
|
_toasts.push({ id, message, type, createdAt: Date.now(), duration: dur, hovered: false });
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|
||||||
if (duration > 0) setTimeout(() => dismissToast(id), duration);
|
if (dur > 0) _scheduleToastDismiss(id, dur);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,17 +54,54 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
|
|||||||
return vnodeList;
|
return vnodeList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the toasts for an apply-all response payload.
|
||||||
|
*
|
||||||
|
* The endpoint returns 200 with `{ applied, errors }` even when some
|
||||||
|
* subsystems failed (e.g. the firewall safety guards refused a change), so
|
||||||
|
* `resp.ok` alone is not a success signal. An error always wins: when any
|
||||||
|
* subsystem failed, report it and suppress the success toast.
|
||||||
|
*
|
||||||
|
* @param {object} data – Response payload `{ applied, errors }`
|
||||||
|
* @param {string} [successMsg] – Message for the success toast
|
||||||
|
* @returns {{error: string|null, success: string|null}}
|
||||||
|
*/
|
||||||
|
export function applyResultToasts(data, successMsg) {
|
||||||
|
const errs = (data && data.errors) || {};
|
||||||
|
const entries = Object.entries(errs);
|
||||||
|
if (entries.length) {
|
||||||
|
return {
|
||||||
|
error: 'Apply failed for: ' +
|
||||||
|
entries.map(([k, v]) => `${k} — ${v}`).join('; '),
|
||||||
|
success: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const applied = (data && data.applied) || [];
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
success: applied.length ? successMsg : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST apply-all, toast result, close modal. State-store models update from
|
* POST apply-all, toast result, close modal. State-store models update from
|
||||||
* the daemon's WS delta — no explicit refresh.
|
* the daemon's WS delta — no explicit refresh.
|
||||||
|
*
|
||||||
|
* @param {string} successMsg – Success toast message
|
||||||
|
* @param {boolean} [force] – Forward `{"force": true}` to override the
|
||||||
|
* firewall safety guards
|
||||||
*/
|
*/
|
||||||
async function doApply(successMsg) {
|
async function doApply(successMsg, force) {
|
||||||
if (isModalProcessing()) return;
|
if (isModalProcessing()) return;
|
||||||
setModalProcessing(true);
|
setModalProcessing(true);
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
|
const opts = { method: 'POST' };
|
||||||
|
if (force) opts.body = { force: true };
|
||||||
|
const resp = await apiFetch('/api/status/apply-all', opts);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
toast(successMsg, 'success');
|
const t = applyResultToasts(resp.data, successMsg);
|
||||||
|
if (t.error) toast(t.error, 'error', 8000);
|
||||||
|
else if (t.success) toast(t.success, 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
// No modelFetch — WS delta updates all affected subsystems.
|
// No modelFetch — WS delta updates all affected subsystems.
|
||||||
} else {
|
} else {
|
||||||
@@ -90,6 +127,12 @@ async function openApplyModal(successMsg) {
|
|||||||
const totalChanges = pendingData.total_changes || 0;
|
const totalChanges = pendingData.total_changes || 0;
|
||||||
|
|
||||||
const expanded = reactive({});
|
const expanded = reactive({});
|
||||||
|
// Only meaningful when the firewall has pending changes (the only
|
||||||
|
// subsystem whose apply honours `force`); the checkbox tracks its own
|
||||||
|
// DOM state — no reactivity needed.
|
||||||
|
const fwPending = isPending(pendingData.firewall) &&
|
||||||
|
((pendingData.firewall.changes || []).length > 0);
|
||||||
|
let force = false;
|
||||||
|
|
||||||
openModal((inner) => {
|
openModal((inner) => {
|
||||||
const rows = buildRows(pendingData, expanded);
|
const rows = buildRows(pendingData, expanded);
|
||||||
@@ -106,7 +149,11 @@ async function openApplyModal(successMsg) {
|
|||||||
modalVNodes(inner, html`<div>
|
modalVNodes(inner, html`<div>
|
||||||
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
||||||
<div class="modal-body">${rows}</div>
|
<div class="modal-body">${rows}</div>
|
||||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
|
${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; }}" />
|
||||||
|
<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>` : ''}
|
||||||
|
<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>`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export function StatusDot(props = {}) {
|
|||||||
return h('span', { class: `status-dot status-${v}` });
|
return h('span', { class: `status-dot status-${v}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small amber dot marking a pending (edited, not yet applied) element.
|
||||||
|
*/
|
||||||
|
export function PendingDot() {
|
||||||
|
return h('span', { class: 'pending-dot' });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty-state placeholder card.
|
* Empty-state placeholder card.
|
||||||
*
|
*
|
||||||
@@ -55,16 +62,20 @@ export function Empty(props = {}) {
|
|||||||
* @param {object} props
|
* @param {object} props
|
||||||
* @param {string} [props.header]
|
* @param {string} [props.header]
|
||||||
* @param {VNode[]} [props.children]
|
* @param {VNode[]} [props.children]
|
||||||
|
* @param {string} [props.cls] - Extra class appended to the outer `div.card`
|
||||||
|
* @param {string} [props.title] - Tooltip on the outer `div.card`
|
||||||
*/
|
*/
|
||||||
export function Card(props = {}) {
|
export function Card(props = {}) {
|
||||||
const key = props.key !== undefined ? { key: props.key } : {};
|
const key = props.key !== undefined ? { key: props.key } : {};
|
||||||
|
const cls = props.cls ? `card ${props.cls}` : 'card';
|
||||||
|
const title = props.title ? { title: props.title } : {};
|
||||||
if (props.header) {
|
if (props.header) {
|
||||||
return h('div', { class: 'card', ...key },
|
return h('div', { class: cls, ...title, ...key },
|
||||||
h('div', { class: 'card-header' }, props.header),
|
h('div', { class: 'card-header' }, props.header),
|
||||||
h('div', { class: 'card-body' }, props.children || []),
|
h('div', { class: 'card-body' }, props.children || []),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return h('div', { class: 'card', ...key }, props.children || []);
|
return h('div', { class: cls, ...title, ...key }, props.children || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,13 +182,22 @@ export function ActionButton(props = {}) {
|
|||||||
if (body !== undefined) opts.body = body;
|
if (body !== undefined) opts.body = body;
|
||||||
const resp = await apiFetch(props.url, opts);
|
const resp = await apiFetch(props.url, opts);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const synced = resp.data?.synced;
|
// Batch endpoints (e.g. /api/status/apply-all) return 200
|
||||||
let msg = props.successMsg || '';
|
// with an `errors` map when some operations failed —
|
||||||
if (synced && synced.length) {
|
// `resp.ok` alone is not a success signal.
|
||||||
if (msg) msg += ' ';
|
const errs = (resp.data && typeof resp.data.errors === 'object') ? resp.data.errors : null;
|
||||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
const errEntries = errs ? Object.entries(errs) : [];
|
||||||
|
if (errEntries.length) {
|
||||||
|
toast('Failed: ' + errEntries.map(([k, v]) => `${k} — ${v}`).join('; '), 'error', 8000);
|
||||||
|
} else {
|
||||||
|
const synced = resp.data?.synced;
|
||||||
|
let msg = props.successMsg || '';
|
||||||
|
if (synced && synced.length) {
|
||||||
|
if (msg) msg += ' ';
|
||||||
|
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||||
|
}
|
||||||
|
if (msg) toast(msg, 'success');
|
||||||
}
|
}
|
||||||
if (msg) toast(msg, 'success');
|
|
||||||
if (props.onSuccess) props.onSuccess();
|
if (props.onSuccess) props.onSuccess();
|
||||||
// No modelFetch — WS delta updates state store models.
|
// No modelFetch — WS delta updates state store models.
|
||||||
} else {
|
} else {
|
||||||
@@ -200,6 +220,8 @@ export function ActionButton(props = {}) {
|
|||||||
* @param {string} [props.emptyText] - Empty-state message
|
* @param {string} [props.emptyText] - Empty-state message
|
||||||
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
|
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
|
||||||
* @param {string} [props.key] - VNode key
|
* @param {string} [props.key] - VNode key
|
||||||
|
* @param {string} [props.cls] - Extra class appended to the wrapper (or div.card)
|
||||||
|
* @param {string} [props.title] - Tooltip on the wrapper element
|
||||||
*/
|
*/
|
||||||
export function Table(props = {}) {
|
export function Table(props = {}) {
|
||||||
const cols = props.columns || [];
|
const cols = props.columns || [];
|
||||||
@@ -216,10 +238,12 @@ export function Table(props = {}) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const key = props.key !== undefined ? { key: props.key } : {};
|
const key = props.key !== undefined ? { key: props.key } : {};
|
||||||
|
const title = props.title ? { title: props.title } : {};
|
||||||
|
const cls = props.cls ? `card ${props.cls}` : 'card';
|
||||||
if (props.wrapCard !== false) {
|
if (props.wrapCard !== false) {
|
||||||
return h('div', { class: 'card', ...key }, table);
|
return h('div', { class: cls, ...title, ...key }, table);
|
||||||
}
|
}
|
||||||
return h('div', key, table);
|
return h('div', { ...title, ...key }, table);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,10 +3,38 @@
|
|||||||
*
|
*
|
||||||
* ToastContainer component that renders queued toast notifications.
|
* ToastContainer component that renders queued toast notifications.
|
||||||
* Uses the toast/dismissToast state from api.js.
|
* Uses the toast/dismissToast state from api.js.
|
||||||
|
*
|
||||||
|
* Long messages (>200 chars or containing newlines) render compact —
|
||||||
|
* first line with an ellipsis — plus a "Details" button that opens a
|
||||||
|
* modal with the full text. Dismissal is only via the × button;
|
||||||
|
* hovering the toast pauses its auto-dismiss timer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { h } from '../vdom.js';
|
import { h } from '../vdom.js';
|
||||||
import { _toasts, dismissToast } from '../api.js';
|
import { _toasts, dismissToast } from '../api.js';
|
||||||
|
import { openModal } from './modal.js';
|
||||||
|
|
||||||
|
/** Messages longer than this (or containing newlines) render compact. */
|
||||||
|
const _LONG_MESSAGE_CHARS = 200;
|
||||||
|
|
||||||
|
function _isLong(message) {
|
||||||
|
return message.length > _LONG_MESSAGE_CHARS || message.includes('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _firstLine(message) {
|
||||||
|
return message.split('\n')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showDetails(t) {
|
||||||
|
openModal(
|
||||||
|
h('div', null,
|
||||||
|
h('h2', { class: 'modal-title' }, 'Details'),
|
||||||
|
h('div', { class: 'modal-body' },
|
||||||
|
h('pre', { class: 'toast-details-msg' }, t.message),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render all pending toast notifications.
|
* Render all pending toast notifications.
|
||||||
@@ -24,19 +52,26 @@ export function ToastContainer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return h('div', { class: 'toast' },
|
return h('div', { class: 'toast' },
|
||||||
..._toasts.map(t =>
|
..._toasts.map(t => {
|
||||||
h('div', {
|
const long = _isLong(t.message);
|
||||||
|
return h('div', {
|
||||||
class: `toast-message ${clsMap[t.type] || clsMap.info}`,
|
class: `toast-message ${clsMap[t.type] || clsMap.info}`,
|
||||||
'on:click': () => dismissToast(t.id),
|
'on:mouseover': () => { t.hovered = true; },
|
||||||
|
'on:mouseout': () => { t.hovered = false; },
|
||||||
},
|
},
|
||||||
h('span', { class: 'toast-text' }, t.message),
|
h('span', { class: 'toast-text' + (long ? ' toast-text-long' : '') },
|
||||||
|
long ? _firstLine(t.message) : t.message),
|
||||||
h('div', { class: 'toast-actions' },
|
h('div', { class: 'toast-actions' },
|
||||||
|
long ? h('button', {
|
||||||
|
class: 'toast-btn toast-details',
|
||||||
|
'on:click': (e) => { e.stopPropagation(); _showDetails(t); },
|
||||||
|
}, 'Details') : null,
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'toast-btn toast-close',
|
class: 'toast-btn toast-close',
|
||||||
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
|
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
|
||||||
}, '\u00d7'),
|
}, '\u00d7'),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Hoover — dirty.js
|
||||||
|
*
|
||||||
|
* Marks UI elements that have been edited (saved to config) but not yet
|
||||||
|
* applied to the live system. Consumes the daemon-provided pending state:
|
||||||
|
* - hash subsystems: status.pending_diff -> [{path, action, old, new}]
|
||||||
|
* - firewall: pending -> {needs_apply, pending:[{zone,type,...}]}
|
||||||
|
*
|
||||||
|
* "Line" matching: an element path is dirty when it shares a root-to-leaf line
|
||||||
|
* with a pending path — equal, an ancestor, or a descendant. A plain key is a
|
||||||
|
* prefix of its indexed form, so a whole-list change (e.g. `dhcp.ranges`)
|
||||||
|
* marks every row, while a leaf change (`interface.listen_port`) marks only
|
||||||
|
* that field/row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function segs(p) {
|
||||||
|
return p ? String(p).split('.').filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is segment `a` a prefix of segment `b`? "ranges" prefixes "ranges[0]"
|
||||||
|
// (the trailing bracket keeps "ranges[1" from prefixing "ranges[12]").
|
||||||
|
function segPrefix(a, b) {
|
||||||
|
return a === b || b.indexOf(a + '[') === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are paths p and q on the same root-to-leaf line? Segment matching is
|
||||||
|
// bidirectional so both directions of containment hold: a pending leaf under
|
||||||
|
// the element (`dhcp.ranges` vs `dhcp.ranges[0].start`) and a pending
|
||||||
|
// container over the element (`dhcp.ranges[3]` vs `dhcp.ranges`).
|
||||||
|
function isLine(p, q) {
|
||||||
|
const A = segs(p);
|
||||||
|
const B = segs(q);
|
||||||
|
if (!A.length || !B.length) return false;
|
||||||
|
const n = Math.min(A.length, B.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (!segPrefix(A[i], B[i]) && !segPrefix(B[i], A[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel path marking "pending but no diff baseline": the config was
|
||||||
|
* saved but never applied, so the daemon has no applied snapshot to diff
|
||||||
|
* against and `pending_diff` is empty while `pending_changes` is true.
|
||||||
|
* Every element is dirty in this case.
|
||||||
|
*/
|
||||||
|
const ANY_PATH = Symbol('dirty: any');
|
||||||
|
|
||||||
|
/** Set of pending config paths from a hash-subsystem status object. */
|
||||||
|
export function dirtySet(status) {
|
||||||
|
const diff = status && Array.isArray(status.pending_diff) ? status.pending_diff : [];
|
||||||
|
const s = new Set();
|
||||||
|
for (const d of diff) {
|
||||||
|
if (d && d.path) s.add(String(d.path));
|
||||||
|
}
|
||||||
|
if (!s.size && status && status.pending_changes) s.add(ANY_PATH);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when element path `path` is (under / above / equal to) a pending change. */
|
||||||
|
export function isDirty(set, path) {
|
||||||
|
if (!set || !set.size) return false;
|
||||||
|
if (set.has(ANY_PATH)) return true;
|
||||||
|
const p = String(path || '');
|
||||||
|
for (const q of set) {
|
||||||
|
if (isLine(p, q)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tooltip listing the concrete pending field(s) that affect `path`. */
|
||||||
|
export function dirtyTitle(set, path) {
|
||||||
|
if (!set || !set.size) return '';
|
||||||
|
if (set.has(ANY_PATH)) return 'Configuration saved but not applied yet';
|
||||||
|
const p = String(path || '');
|
||||||
|
const hits = [...set].filter((q) => isLine(p, q)).sort();
|
||||||
|
if (!hits.length) return '';
|
||||||
|
return 'Unapplied changes: ' + hits.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One object for a hash-subsystem element. Use class/title on the element. */
|
||||||
|
export function dirtyInfo(set, path) {
|
||||||
|
const dirty = isDirty(set, path);
|
||||||
|
return {
|
||||||
|
dirty,
|
||||||
|
class: dirty ? 'config-dirty' : '',
|
||||||
|
title: dirty ? dirtyTitle(set, path) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLEAN_INFO = { dirty: false, class: '', title: '' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One object for a container element, covering pending changes under `root`
|
||||||
|
* that no longer have a live child element to mark: a removed dict key
|
||||||
|
* (e.g. `peers.p1`) leaves its pending path with no row/section for a
|
||||||
|
* per-element marker to attach to. `children` is the list of element paths
|
||||||
|
* for the container's live children (e.g. `'peers.' + name` per configured
|
||||||
|
* peer). Clean when there are no such orphaned paths, when the set is the
|
||||||
|
* never-applied sentinel (every element is already marked), or when the root
|
||||||
|
* itself is pending (every child row is marked instead).
|
||||||
|
*/
|
||||||
|
export function orphanInfo(set, root, children) {
|
||||||
|
if (!set || !set.size || set.has(ANY_PATH)) return CLEAN_INFO;
|
||||||
|
const r = String(root || '');
|
||||||
|
const childList = (children || []).map(String);
|
||||||
|
const hits = [];
|
||||||
|
for (const q of set) {
|
||||||
|
if (q === r || !isLine(r, q)) continue;
|
||||||
|
if (!childList.some((cp) => isLine(q, cp))) hits.push(q);
|
||||||
|
}
|
||||||
|
if (!hits.length) return CLEAN_INFO;
|
||||||
|
return {
|
||||||
|
dirty: true,
|
||||||
|
class: 'config-dirty',
|
||||||
|
title: 'Unapplied changes: ' + hits.sort().join(', '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Firewall (zone + type granularity) ─────────────────────────
|
||||||
|
|
||||||
|
/** Map<zone, Set<type>> from a firewall pending object. */
|
||||||
|
export function fwDirty(pending) {
|
||||||
|
const m = new Map();
|
||||||
|
const list = pending && Array.isArray(pending.pending) ? pending.pending : [];
|
||||||
|
for (const c of list) {
|
||||||
|
if (!c || !c.zone) continue;
|
||||||
|
if (!m.has(c.zone)) m.set(c.zone, new Set());
|
||||||
|
if (c.type) m.get(c.zone).add(c.type);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `zone` (and optionally `type`) has a pending firewall change. */
|
||||||
|
export function fwIsDirty(map, zone, type) {
|
||||||
|
if (!map || !map.size) return false;
|
||||||
|
const types = map.get(zone);
|
||||||
|
if (!types) return false;
|
||||||
|
if (type) return types.has(type);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tooltip for a firewall zone (and optional type). */
|
||||||
|
export function fwTitle(map, zone, type) {
|
||||||
|
const types = map.get(zone);
|
||||||
|
if (!types) return '';
|
||||||
|
const t = [...types].sort();
|
||||||
|
const shown = type ? t.filter((x) => x === type) : t;
|
||||||
|
if (!shown.length) return '';
|
||||||
|
return 'Unapplied changes: ' + shown.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One object for a firewall element (zone, optional type). */
|
||||||
|
export function fwInfo(map, zone, type) {
|
||||||
|
const dirty = fwIsDirty(map, zone, type);
|
||||||
|
return {
|
||||||
|
dirty,
|
||||||
|
class: dirty ? 'config-dirty' : '',
|
||||||
|
title: dirty ? fwTitle(map, zone, type) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob }
|
|||||||
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js';
|
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js';
|
||||||
|
|
||||||
/* ── UI Components: Data ─────────────────────────────────────── */
|
/* ── UI Components: Data ─────────────────────────────────────── */
|
||||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js';
|
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect, PendingDot } from './components/data.js';
|
||||||
|
|
||||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||||
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js';
|
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js';
|
||||||
@@ -60,3 +60,6 @@ export { ToastContainer } from './components/toast.js';
|
|||||||
|
|
||||||
/* ── UI Components: QR Code ──────────────────────────────────── */
|
/* ── UI Components: QR Code ──────────────────────────────────── */
|
||||||
export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js';
|
export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js';
|
||||||
|
|
||||||
|
/* ── Dirty / pending-edit markers ─────────────────────────────── */
|
||||||
|
export { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from './dirty.js';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js';
|
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||||
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js';
|
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js';
|
||||||
import { _deleting } from '/static/hoover/components/data.js';
|
import { _deleting } from '/static/hoover/components/data.js';
|
||||||
|
|
||||||
@@ -260,18 +260,22 @@ export default definePage({
|
|||||||
return {
|
return {
|
||||||
backends: getModel('backends'),
|
backends: getModel('backends'),
|
||||||
dnsmasq: getModel('dnsmasq'),
|
dnsmasq: getModel('dnsmasq'),
|
||||||
|
nginx: getModel('nginx'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
|
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const set = dirtySet(state.nginx.data?.status);
|
||||||
const backends = state.backends.data || {};
|
const backends = state.backends.data || {};
|
||||||
const entries = Object.entries(backends);
|
const entries = Object.entries(backends);
|
||||||
|
|
||||||
const rows = entries.map(([name, b]) =>
|
const rows = entries.map(([name, b]) => {
|
||||||
html`<tr key=${name} class=${_deleting.has(name) ? 'pending-delete' : ''}>
|
const info = dirtyInfo(set, 'backends.' + name);
|
||||||
<td><strong>${esc(name)}</strong></td>
|
const cls = (_deleting.has(name) ? 'pending-delete' : '') + (info.class ? ' ' + info.class : '');
|
||||||
|
return html`<tr key=${name} class=${cls || undefined} title=${info.title || undefined}>
|
||||||
|
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(name)}</strong></td>
|
||||||
<td>${esc(b.label || name)}</td>
|
<td>${esc(b.label || name)}</td>
|
||||||
<td>${Object.keys(b.paths || {}).length}</td>
|
<td>${Object.keys(b.paths || {}).length}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -291,11 +295,11 @@ export default definePage({
|
|||||||
message=${'Remove backend ' + enc(name) + '?'}
|
message=${'Remove backend ' + enc(name) + '?'}
|
||||||
success="Backend removed"
|
success="Backend removed"
|
||||||
onComplete=${() => modelFetch('backends')}
|
onComplete=${() => modelFetch('backends')}
|
||||||
label="Delete" />`
|
label="Delete" />`}
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>`
|
</tr>`;
|
||||||
);
|
});
|
||||||
|
|
||||||
const actions = ActionGroup(
|
const actions = ActionGroup(
|
||||||
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
|
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ export default definePage({
|
|||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const account = state.acme.data?.account || { registered: false, email: '', ca: '' };
|
const account = state.acme.data?.account || { registered: false, email: '', ca: '' };
|
||||||
|
const certError = state.acme.data?.status?.error;
|
||||||
const rows = (state.acme.data?.certs || []).map(c => {
|
const rows = (state.acme.data?.certs || []).map(c => {
|
||||||
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
||||||
|
|
||||||
@@ -363,6 +364,15 @@ export default definePage({
|
|||||||
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
|
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
|
||||||
}),
|
}),
|
||||||
_accountCard(account),
|
_accountCard(account),
|
||||||
|
certError
|
||||||
|
? html`<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-warning text-sm">
|
||||||
|
Certificate data unavailable: ${esc(certError)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: null,
|
||||||
rows.length
|
rows.length
|
||||||
? Table({
|
? Table({
|
||||||
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
|
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
|
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ApplyConfirm, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
|
||||||
|
|
||||||
// Render a single firewall change as "current → new".
|
// Render a single firewall change as "current → new".
|
||||||
// `live` is the currently applied value; `config` is the target value it
|
// `live` is the currently applied value; `config` is the target value it
|
||||||
@@ -158,7 +158,7 @@ export default definePage({
|
|||||||
</li>`)}
|
</li>`)}
|
||||||
</ul>
|
</ul>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
<${ApplyConfirm} pending=${true} label="Apply All Changes"
|
||||||
successMsg="All changes applied"
|
successMsg="All changes applied"
|
||||||
cls="btn btn-sm btn-primary" />
|
cls="btn btn-sm btn-primary" />
|
||||||
<${CancelConfirm} cls="btn btn-sm btn-danger" />
|
<${CancelConfirm} cls="btn btn-sm btn-danger" />
|
||||||
|
|||||||
+22
-11
@@ -1,4 +1,4 @@
|
|||||||
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
|
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||||
|
|
||||||
function makeAddRange(activeZones, interfaces) {
|
function makeAddRange(activeZones, interfaces) {
|
||||||
const opts = [
|
const opts = [
|
||||||
@@ -118,6 +118,7 @@ export default definePage({
|
|||||||
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
|
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const set = dirtySet(state.dnsmasq.data?.status);
|
||||||
const cfg = state.dnsmasq.data?.config || {};
|
const cfg = state.dnsmasq.data?.config || {};
|
||||||
const dhcpCfg = cfg.dhcp || {};
|
const dhcpCfg = cfg.dhcp || {};
|
||||||
const dnsCfg = cfg.dns || {};
|
const dnsCfg = cfg.dns || {};
|
||||||
@@ -126,8 +127,10 @@ export default definePage({
|
|||||||
const dnsRecords = dnsCfg.custom_records || [];
|
const dnsRecords = dnsCfg.custom_records || [];
|
||||||
const status = state.dnsmasq.data?.status || {};
|
const status = state.dnsmasq.data?.status || {};
|
||||||
|
|
||||||
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
|
const rangesRows = ranges.map((r, i) => {
|
||||||
<td>${r.interface || '(global)'}</td>
|
const info = dirtyInfo(set, 'dhcp.ranges[' + i + ']');
|
||||||
|
return html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end} class=${info.class || undefined} title=${info.title || undefined}>
|
||||||
|
<td>${info.dirty ? PendingDot({}) : ''}${r.interface || '(global)'}</td>
|
||||||
<td>${esc(r.start)}</td>
|
<td>${esc(r.start)}</td>
|
||||||
<td>${esc(r.end)}</td>
|
<td>${esc(r.end)}</td>
|
||||||
<td>${esc(r.lease_time || '12h')}</td>
|
<td>${esc(r.lease_time || '12h')}</td>
|
||||||
@@ -139,10 +142,13 @@ export default definePage({
|
|||||||
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
|
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
|
||||||
success="Range removed" />
|
success="Range removed" />
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
|
const leaseRows = staticLeases.map((l, i) => {
|
||||||
<td>${esc(l.mac)}</td>
|
const info = dirtyInfo(set, 'dhcp.static_leases[' + i + ']');
|
||||||
|
return html`<tr key=${l.mac} class=${info.class || undefined} title=${info.title || undefined}>
|
||||||
|
<td>${info.dirty ? PendingDot({}) : ''}${esc(l.mac)}</td>
|
||||||
<td>${esc(l.ip)}</td>
|
<td>${esc(l.ip)}</td>
|
||||||
<td>${l.hostname || '-'}</td>
|
<td>${l.hostname || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -152,10 +158,13 @@ export default definePage({
|
|||||||
message=${'Remove lease ' + l.mac + '?'}
|
message=${'Remove lease ' + l.mac + '?'}
|
||||||
success="Lease removed" />
|
success="Lease removed" />
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
|
const dnsRows = dnsRecords.map((rec, i) => {
|
||||||
<td><strong>${esc(rec.name || 'unnamed')}</strong></td>
|
const info = dirtyInfo(set, 'dns.custom_records[' + i + ']');
|
||||||
|
return html`<tr key=${rec.name} class=${info.class || undefined} title=${info.title || undefined}>
|
||||||
|
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(rec.name || 'unnamed')}</strong></td>
|
||||||
<td class="text-sm">${esc(rec.address || '-')}</td>
|
<td class="text-sm">${esc(rec.address || '-')}</td>
|
||||||
<td>
|
<td>
|
||||||
<${ConfirmDelete}
|
<${ConfirmDelete}
|
||||||
@@ -164,7 +173,8 @@ export default definePage({
|
|||||||
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
|
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
|
||||||
success="Record removed" />
|
success="Record removed" />
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
const _setDomain = async (domain) => {
|
const _setDomain = async (domain) => {
|
||||||
const res = await apiFetch('/api/dhcp/domain', {
|
const res = await apiFetch('/api/dhcp/domain', {
|
||||||
@@ -180,7 +190,8 @@ export default definePage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentDomain = dnsCfg.domain || null;
|
const currentDomain = dnsCfg.domain || null;
|
||||||
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
|
const domainInfo = dirtyInfo(set, 'dns.domain');
|
||||||
|
const domainSection = html`<div class="domain-config ${domainInfo.class}" title=${domainInfo.title || undefined} style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600;">Search Domain</label>
|
<label style="font-weight: 600;">Search Domain</label>
|
||||||
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
|
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
|
||||||
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
|
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
|
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||||
|
|
||||||
async function changeZone(name, zone, state) {
|
async function changeZone(name, zone, state) {
|
||||||
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
||||||
@@ -45,6 +45,7 @@ export default definePage({
|
|||||||
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
|
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const set = dirtySet(state.network.data?.status);
|
||||||
const fwZones = state.firewall.data?.zones || {};
|
const fwZones = state.firewall.data?.zones || {};
|
||||||
const netData = state.network.data?.interfaces || {};
|
const netData = state.network.data?.interfaces || {};
|
||||||
const zones = Object.keys(fwZones);
|
const zones = Object.keys(fwZones);
|
||||||
@@ -72,9 +73,10 @@ export default definePage({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = ifaces.map(iface =>
|
const rows = ifaces.map(iface => {
|
||||||
html`<tr key=${iface.name}>
|
const info = dirtyInfo(set, 'interfaces.' + iface.name);
|
||||||
<td><strong>${iface.name}</strong></td>
|
return html`<tr key=${iface.name} class=${info.class || undefined} title=${info.title || undefined}>
|
||||||
|
<td>${info.dirty ? PendingDot({}) : ''}<strong>${iface.name}</strong></td>
|
||||||
<td class="text-muted">${String(iface.mac || 'N/A')}</td>
|
<td class="text-muted">${String(iface.mac || 'N/A')}</td>
|
||||||
<td>${(iface.ips || []).join(', ') || 'N/A'}</td>
|
<td>${(iface.ips || []).join(', ') || 'N/A'}</td>
|
||||||
<td><${StatusText} status=${iface.state} /></td>
|
<td><${StatusText} status=${iface.state} /></td>
|
||||||
@@ -84,8 +86,8 @@ export default definePage({
|
|||||||
<button class="btn btn-sm btn-outline" style="margin-left:8px"
|
<button class="btn btn-sm btn-outline" style="margin-left:8px"
|
||||||
onClick=${() => cfgModalFn(iface)}>Config</button>
|
onClick=${() => cfgModalFn(iface)}>Config</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`
|
</tr>`;
|
||||||
);
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
|
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js';
|
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
|
||||||
|
|
||||||
const addFwd = QuickModal({
|
const addFwd = QuickModal({
|
||||||
title: 'Add Port Forward',
|
title: 'Add Port Forward',
|
||||||
@@ -33,6 +33,7 @@ export default definePage({
|
|||||||
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
|
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const fw = fwDirty(state.firewall.data?.pending);
|
||||||
const cfg = state.firewall.data?.config || {};
|
const cfg = state.firewall.data?.config || {};
|
||||||
const zoneData = cfg.zones || {};
|
const zoneData = cfg.zones || {};
|
||||||
|
|
||||||
@@ -82,8 +83,9 @@ export default definePage({
|
|||||||
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
|
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
return html`<tr key=${'m-' + zone}>
|
const info = fwInfo(fw, zone, 'masquerade');
|
||||||
<td><strong>${zone}</strong></td>
|
return html`<tr key=${'m-' + zone} class=${info.class || undefined} title=${info.title || undefined}>
|
||||||
|
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
|
||||||
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
|
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
|
||||||
<td>
|
<td>
|
||||||
<${ActionButton}
|
<${ActionButton}
|
||||||
@@ -102,8 +104,9 @@ export default definePage({
|
|||||||
forwards.forEach((fwd, i) => {
|
forwards.forEach((fwd, i) => {
|
||||||
const port = fwd.port;
|
const port = fwd.port;
|
||||||
const proto = fwd['proxy-protocol'] || fwd.proto;
|
const proto = fwd['proxy-protocol'] || fwd.proto;
|
||||||
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
|
const info = fwInfo(fw, zone, 'forward_ports');
|
||||||
<td><strong>${zone}</strong></td>
|
fwRows.push(html`<tr key=${'f-' + zone + '-' + i} class=${info.class || undefined} title=${info.title || undefined}>
|
||||||
|
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
|
||||||
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
|
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
|
||||||
<td>${port}</td>
|
<td>${port}</td>
|
||||||
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
|
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js';
|
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||||
import { openBackendModal } from '/static/pages/backends.js';
|
import { openBackendModal } from '/static/pages/backends.js';
|
||||||
|
|
||||||
function certLookup(acmeData) {
|
function certLookup(acmeData) {
|
||||||
@@ -172,8 +172,9 @@ function editDomain(d, state) {
|
|||||||
modal({});
|
modal({});
|
||||||
}
|
}
|
||||||
|
|
||||||
function domainRow(domainName, domainPaths, state) {
|
function domainRow(domainName, domainPaths, state, set) {
|
||||||
const d = domainPaths[0];
|
const d = domainPaths[0];
|
||||||
|
const info = dirtyInfo(set, 'domains.' + domainName);
|
||||||
const certMap = certLookup(state.acme ? state.acme.data : null);
|
const certMap = certLookup(state.acme ? state.acme.data : null);
|
||||||
const cert = certMap[d.domain];
|
const cert = certMap[d.domain];
|
||||||
let certBadge, certTitle;
|
let certBadge, certTitle;
|
||||||
@@ -199,8 +200,8 @@ function domainRow(domainName, domainPaths, state) {
|
|||||||
if (flags.length) parts.push(flags.join(', '));
|
if (flags.length) parts.push(flags.join(', '));
|
||||||
return parts.join(' → ');
|
return parts.join(' → ');
|
||||||
});
|
});
|
||||||
return html`<tr key=${domainName} class="domain-row">
|
return html`<tr key=${domainName} class="domain-row ${info.class}" title=${info.title || undefined}>
|
||||||
<td><strong>${esc(domainName)}</strong></td>
|
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(domainName)}</strong></td>
|
||||||
<td>${pathSummaries}</td>
|
<td>${pathSummaries}</td>
|
||||||
<td title=${certTitle}>${certBadge}</td>
|
<td title=${certTitle}>${certBadge}</td>
|
||||||
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
|
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
|
||||||
@@ -216,9 +217,10 @@ function domainRow(domainName, domainPaths, state) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function backendSection(section, state) {
|
function backendSection(section, state, set) {
|
||||||
const { backendName, backend, domains } = section;
|
const { backendName, backend, domains } = section;
|
||||||
const rows = domains.map(d => domainRow(d.domain, d.paths, state));
|
const info = dirtyInfo(set, 'backends.' + backendName);
|
||||||
|
const rows = domains.map(d => domainRow(d.domain, d.paths, state, set));
|
||||||
const sectionActions = [];
|
const sectionActions = [];
|
||||||
if (!backend.builtin) {
|
if (!backend.builtin) {
|
||||||
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
|
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
|
||||||
@@ -233,7 +235,7 @@ function backendSection(section, state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
|
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
|
||||||
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;">
|
return html`<div class="backend-section ${info.class}" title=${info.title || undefined} key=${backendName} style="margin-bottom:24px;">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
|
||||||
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
|
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
|
||||||
<${Badge} text=${esc(backendName)} variant="primary" />
|
<${Badge} text=${esc(backendName)} variant="primary" />
|
||||||
@@ -258,10 +260,11 @@ export default definePage({
|
|||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
|
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
const set = dirtySet(state.nginx.data?.status);
|
||||||
const domains = state.nginx.data.domains || [];
|
const domains = state.nginx.data.domains || [];
|
||||||
const backends = state.backends.data || {};
|
const backends = state.backends.data || {};
|
||||||
const sections = _groupByBackend(domains, backends);
|
const sections = _groupByBackend(domains, backends);
|
||||||
const sectionVNodes = sections.map(s => backendSection(s, state));
|
const sectionVNodes = sections.map(s => backendSection(s, state, set));
|
||||||
const actions = ActionGroup(
|
const actions = ActionGroup(
|
||||||
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
||||||
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
|
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js';
|
import { h, html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
|
||||||
|
|
||||||
const addRule = QuickModal({
|
const addRule = QuickModal({
|
||||||
title: 'Add Rich Rule',
|
title: 'Add Rich Rule',
|
||||||
@@ -24,6 +24,7 @@ export default definePage({
|
|||||||
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
|
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const fw = fwDirty(state.firewall.data?.pending);
|
||||||
const cfg = state.firewall.data?.config || {};
|
const cfg = state.firewall.data?.config || {};
|
||||||
const zones = Object.keys(state.firewall.data?.zones || {});
|
const zones = Object.keys(state.firewall.data?.zones || {});
|
||||||
const zoneData = cfg.zones || {};
|
const zoneData = cfg.zones || {};
|
||||||
@@ -34,6 +35,7 @@ export default definePage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
|
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
|
||||||
|
const info = fwInfo(fw, zone, 'rich_rules');
|
||||||
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
|
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
|
||||||
const ruleId = typeof entry === 'object' ? entry.id : null;
|
const ruleId = typeof entry === 'object' ? entry.id : null;
|
||||||
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
|
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
|
||||||
@@ -50,8 +52,12 @@ export default definePage({
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
return Card({
|
return Card({
|
||||||
header: 'Zone: ' + esc(zone),
|
header: info.dirty
|
||||||
|
? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)])
|
||||||
|
: 'Zone: ' + esc(zone),
|
||||||
key: zone,
|
key: zone,
|
||||||
|
cls: info.class || undefined,
|
||||||
|
title: info.title || undefined,
|
||||||
children: [Table({
|
children: [Table({
|
||||||
columns: ['#', 'Rule', 'Action'],
|
columns: ['#', 'Rule', 'Action'],
|
||||||
rows: ruleRows,
|
rows: ruleRows,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** WireGuard page — tunnel & peer management. */
|
/** WireGuard page — tunnel & peer management. */
|
||||||
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
|
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr, PendingDot, dirtySet, dirtyInfo, orphanInfo } from '/static/hoover/index.js';
|
||||||
|
|
||||||
/* ── LAN detection helper ────────────────────────────────────── */
|
/* ── LAN detection helper ────────────────────────────────────── */
|
||||||
function getLanSubnets() {
|
function getLanSubnets() {
|
||||||
@@ -351,6 +351,7 @@ function renderAccessClasses(config, status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const classStatuses = status?.classes || {};
|
const classStatuses = status?.classes || {};
|
||||||
|
const set = dirtySet(status);
|
||||||
|
|
||||||
const rows = entries.map(([k, v]) => {
|
const rows = entries.map(([k, v]) => {
|
||||||
const pCount = peerCountMap[k] || 0;
|
const pCount = peerCountMap[k] || 0;
|
||||||
@@ -358,8 +359,9 @@ function renderAccessClasses(config, status) {
|
|||||||
const isUp = clsStatus.up;
|
const isUp = clsStatus.up;
|
||||||
const hasKeys = classHasKeys(v);
|
const hasKeys = classHasKeys(v);
|
||||||
const color = classColor(k);
|
const color = classColor(k);
|
||||||
return html`<tr key=${k}>
|
const info = dirtyInfo(set, 'access_classes.' + k);
|
||||||
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
|
return html`<tr key=${k} class=${info.class || undefined} title=${info.title || undefined}>
|
||||||
|
<td style="border-left: 3px solid ${color}">${info.dirty ? PendingDot({}) : ''}<strong>${esc(k)}</strong></td>
|
||||||
<td>${esc(v.name || k)}</td>
|
<td>${esc(v.name || k)}</td>
|
||||||
<td class="text-sm">${esc(v.description || '-')}</td>
|
<td class="text-sm">${esc(v.description || '-')}</td>
|
||||||
<td class="text-sm">${esc(v.subnet || '-')}</td>
|
<td class="text-sm">${esc(v.subnet || '-')}</td>
|
||||||
@@ -418,12 +420,16 @@ export default definePage({
|
|||||||
const wgData = state.wireguard.data;
|
const wgData = state.wireguard.data;
|
||||||
const st = wgData?.status || {};
|
const st = wgData?.status || {};
|
||||||
const config = wgData?.config || {};
|
const config = wgData?.config || {};
|
||||||
|
const set = dirtySet(st);
|
||||||
const isUp = st.up || false;
|
const isUp = st.up || false;
|
||||||
const listenPort = config.interface?.listen_port || '-';
|
const listenPort = config.interface?.listen_port || '-';
|
||||||
const serverEndpoint = config.interface?.server_endpoint || '';
|
const serverEndpoint = config.interface?.server_endpoint || '';
|
||||||
|
|
||||||
// Build merged peer rows: configured peers + live status
|
// Build merged peer rows: configured peers + live status
|
||||||
const configuredPeers = wgData?.peers || [];
|
const configuredPeers = wgData?.peers || [];
|
||||||
|
// A removed peer leaves a `peers.<name>` pending path with no live row
|
||||||
|
// to attach a per-row marker to; surface it on the table itself.
|
||||||
|
const peersOrphan = orphanInfo(set, 'peers', configuredPeers.map(p => 'peers.' + p.name));
|
||||||
const statusPeersMap = {};
|
const statusPeersMap = {};
|
||||||
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
|
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
|
||||||
for (const sp of (cSt.peers || [])) {
|
for (const sp of (cSt.peers || [])) {
|
||||||
@@ -442,9 +448,11 @@ export default definePage({
|
|||||||
const isConnected = sp && !!sp.latest_handshake;
|
const isConnected = sp && !!sp.latest_handshake;
|
||||||
const accessClass = p.access_class;
|
const accessClass = p.access_class;
|
||||||
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
|
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
|
||||||
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
|
const info = dirtyInfo(set, 'peers.' + p.name);
|
||||||
return html`<tr key=${p.name}${borderColor}>
|
const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined;
|
||||||
|
return html`<tr key=${p.name} class=${info.class || undefined} title=${info.title || undefined} style=${style}>
|
||||||
<td>
|
<td>
|
||||||
|
${info.dirty ? PendingDot({}) : ''}
|
||||||
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
|
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
|
||||||
<strong>${esc(p.name || 'unnamed')}</strong>
|
<strong>${esc(p.name || 'unnamed')}</strong>
|
||||||
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
|
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
|
||||||
@@ -480,7 +488,8 @@ export default definePage({
|
|||||||
const isUp = cSt.up;
|
const isUp = cSt.up;
|
||||||
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
|
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
|
||||||
const color = classColor(k);
|
const color = classColor(k);
|
||||||
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
|
const info = dirtyInfo(set, 'access_classes.' + k);
|
||||||
|
return html`<div key=${k} class="card ${info.class}" title=${info.title || undefined} style="border-left: 3px solid ${color}">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
|
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
|
||||||
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
|
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
|
||||||
@@ -502,9 +511,10 @@ export default definePage({
|
|||||||
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
|
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ifaceInfo = dirtyInfo(set, 'interface');
|
||||||
const actions = ActionGroup(
|
const actions = ActionGroup(
|
||||||
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
|
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
|
||||||
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
|
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title=${'Interface Settings' + (ifaceInfo.dirty ? ' — ' + ifaceInfo.title : '')}>${ifaceInfo.dirty ? PendingDot({}) : ''}\u{1F527}</button>`,
|
||||||
ActionButton({
|
ActionButton({
|
||||||
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
||||||
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
|
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
|
||||||
@@ -531,6 +541,8 @@ export default definePage({
|
|||||||
? Table({
|
? Table({
|
||||||
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
|
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
|
||||||
rows: peerRows,
|
rows: peerRows,
|
||||||
|
cls: peersOrphan.class || undefined,
|
||||||
|
title: peersOrphan.title || undefined,
|
||||||
})
|
})
|
||||||
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
||||||
renderAccessClasses(config, st),
|
renderAccessClasses(config, st),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js';
|
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal, PendingDot, fwDirty, fwInfo, fwTitle } from '/static/hoover/index.js';
|
||||||
|
|
||||||
// Services shown by default in the service picker. Everything else is only
|
// Services shown by default in the service picker. Everything else is only
|
||||||
// visible with the "Show all options" toggle (or while it is already
|
// visible with the "Show all options" toggle (or while it is already
|
||||||
@@ -33,6 +33,8 @@ export default definePage({
|
|||||||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
|
const fw = fwDirty(state.firewall.data?.pending);
|
||||||
|
|
||||||
// Live zone data (parsed `--list-all-zones`): carries interfaces,
|
// Live zone data (parsed `--list-all-zones`): carries interfaces,
|
||||||
// services, target, and masquerade for every defined zone.
|
// services, target, and masquerade for every defined zone.
|
||||||
const liveZones = state.firewall.data?.zones || {};
|
const liveZones = state.firewall.data?.zones || {};
|
||||||
@@ -45,22 +47,26 @@ export default definePage({
|
|||||||
const z = typeof zdata === 'object' ? zdata : {};
|
const z = typeof zdata === 'object' ? zdata : {};
|
||||||
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
||||||
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
||||||
return html`<div class="card" key=${name} style="position:relative">
|
const info = fwInfo(fw, name);
|
||||||
|
const ifTitle = fwTitle(fw, name, 'interfaces');
|
||||||
|
const svcTitle = fwTitle(fw, name, 'services');
|
||||||
|
const tgtTitle = fwTitle(fw, name, 'target');
|
||||||
|
return html`<div class="card ${info.class}" title=${info.title || undefined} key=${name} style="position:relative">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
|
<h3 style="font-size:16px;color:var(--accent)">${info.dirty ? PendingDot({}) : ''}${name}</h3>
|
||||||
<div class="text-muted text-sm" style="margin-bottom:10px">
|
<div class="text-muted text-sm" title=${tgtTitle || undefined} style="margin-bottom:10px">
|
||||||
${z.target ? 'Target: ' + esc(z.target) : ''}
|
${z.target ? 'Target: ' + esc(z.target) : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm mb-4">
|
<div class="text-sm mb-4" title=${ifTitle || undefined}>
|
||||||
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
|
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
|
||||||
${ifacesArr.length
|
${ifacesArr.length
|
||||||
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
|
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
|
||||||
: html`<span class="text-muted">None</span>`}
|
: html`<span class="text-muted">None</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm mb-4">
|
<div class="text-sm mb-4" title=${svcTitle || undefined}>
|
||||||
<div class="text-muted" style="margin-bottom:4px">Services</div>
|
<div class="text-muted" style="margin-bottom:4px">Services</div>
|
||||||
${svcsArr.length
|
${svcsArr.length
|
||||||
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
|
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
|
||||||
|
|||||||
@@ -380,6 +380,37 @@ body {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact rendering for long messages: single line, ellipsized. */
|
||||||
|
.toast-message .toast-text-long {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message .toast-details {
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full message inside the toast Details modal. */
|
||||||
|
.toast-details-msg {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.toast-message .toast-actions {
|
.toast-message .toast-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -1062,6 +1093,37 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pending (edited, not yet applied) marker — amber, distinct from the red
|
||||||
|
.pending-delete. Applied to rows/cards/sections whose config is dirty. */
|
||||||
|
.config-dirty {
|
||||||
|
background: rgba(243, 156, 18, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.config-dirty > td:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A row queued for deletion (red) takes precedence over the dirty marker. */
|
||||||
|
tr.pending-delete.config-dirty > td:first-child {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.config-dirty,
|
||||||
|
.backend-section.config-dirty,
|
||||||
|
.domain-config.config-dirty {
|
||||||
|
box-shadow: inset 3px 0 0 var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--warning);
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
|||||||
Reference in New Issue
Block a user