diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index 48c16bc..e507782 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -34,7 +34,7 @@ from daemon.iface import ( POST_FIREWALL_ZONES_SERVICES, ) from daemon.server import ConflictError, NotFoundError, refresh_state, registry -from lib.common import load_json, run, save_json +from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta from lib.firewall import ( _normalize_target, _parse_active_zones, @@ -391,6 +391,11 @@ def _config_apply(force: bool = False) -> dict[str, Any]: "timestamp": "", } backup_path = _save_backup(full_state) + # Record the applied config snapshot + hash so pending-changes detection + # and cancel/revert work like the hash-based subsystems. + applied_cfg = _get_config() + stamp_applied(applied_cfg) + _save_config(applied_cfg) logger.info("Firewall config applied to %d zones", len(applied)) return { "applied_zones": applied, @@ -506,9 +511,9 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]: _body: The request body (unused). Returns: - Full firewall config dict. + Full firewall config dict (apply bookkeeping keys stripped). """ - return _get_config() + return strip_apply_meta(_get_config()) @registry.register(POST_FIREWALL_CONFIG) diff --git a/daemon/handlers/status.py b/daemon/handlers/status.py index 6a147a0..a78a9a5 100644 --- a/daemon/handlers/status.py +++ b/daemon/handlers/status.py @@ -1,21 +1,33 @@ """Aggregate status handler. -Exposes pending changes across all subsystems and a single apply-all -endpoint that invokes each subsystem's apply in the correct order. +Exposes pending changes across all subsystems, a single apply-all +endpoint that invokes each subsystem's apply in the correct order, and a +cancel-all endpoint that reverts pending edits to the last applied config. """ from __future__ import annotations import logging +from pathlib import Path from typing import Any +from daemon.handlers import dnsmasq as _dnsmasq_h +from daemon.handlers import firewall as _firewall_h +from daemon.handlers import nginx as _nginx_h from daemon.handlers.dnsmasq import apply_config as dnsmasq_apply_config from daemon.handlers.firewall import config_apply as firewall_config_apply from daemon.handlers.network import apply_all as network_apply_all from daemon.handlers.nginx import apply as nginx_apply from daemon.handlers.wireguard import apply as wireguard_apply -from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL +from daemon.iface import ( + GET_STATUS_PENDING, + POST_STATUS_APPLY_ALL, + POST_STATUS_CANCEL_ALL, +) from daemon.server import refresh_state, registry +from lib import network as _net +from lib import wireguard as _wg +from lib.common import revert_to_applied from lib.firewall import fw_change_summary from lib.state import state as state_store @@ -36,6 +48,15 @@ SYS_APPLY = { "dnsmasq": dnsmasq_apply_config, "nginx": nginx_apply, } +# (module, attribute) pairs for each subsystem's on-disk config path. +# Resolved at call time so tests can monkeypatch the module constants. +SYS_CONFIG_PATHS: dict[str, tuple[Any, str]] = { + "firewall": (_firewall_h, "CONFIG_FILE"), + "dnsmasq": (_dnsmasq_h, "CONFIG_PATH"), + "nginx": (_nginx_h, "CONFIG_FILE"), + "wireguard": (_wg, "CONFIG_PATH"), + "networkd": (_net, "CONFIG_FILE"), +} @registry.register(GET_STATUS_PENDING) @@ -126,6 +147,65 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]: return {"applied": applied, "errors": errors} +def _config_path(name: str) -> Path: + """Return the on-disk config path for subsystem *name*. + + Resolved via the owning module at call time so tests can monkeypatch + the module constants (e.g. ``lib.wireguard.CONFIG_PATH``). + """ + module, attr = SYS_CONFIG_PATHS[name] + return getattr(module, attr) + + +@registry.register(POST_STATUS_CANCEL_ALL) +def status_cancel_all(_request: Any, _body: Any) -> dict[str, Any]: + """Revert pending changes for all subsystems to the last applied config. + + Restores each pending subsystem's config file from its recorded + ``_last_applied_config`` snapshot, discarding unapplied edits. + Subsystems without a recorded baseline (never applied) are skipped + with a reason instead of being reset. No live-system commands run — + cancel only touches the declarative config files. + + Returns: + Dict with ``cancelled`` (list of reverted subsystems), + ``skipped`` (label -> reason), and ``errors`` (label -> message). + """ + pending_data = status_pending(None, None) + fw_pending = pending_data["firewall"]["needs_apply"] + hash_pending = { + "dnsmasq": pending_data["dnsmasq"]["pending_changes"], + "nginx": pending_data["nginx"]["pending_changes"], + "wireguard": pending_data["wireguard"]["pending_changes"], + "networkd": pending_data["networkd"]["pending_changes"], + } + + cancelled: list[str] = [] + skipped: dict[str, str] = {} + errors: dict[str, str] = {} + + for name in SYS_ORDER: + pending = fw_pending if name == "firewall" else hash_pending.get(name, False) + if not pending: + continue + label = SYS_LABELS.get(name, name) + try: + ok, reason = revert_to_applied(_config_path(name)) + if ok: + cancelled.append(name) + logger.info("Cancelled pending changes for %s", name) + else: + skipped[label] = reason + logger.warning("Cancel-all skipped %s: %s", label, reason) + except Exception as exc: + errors[label] = str(exc) + logger.error("Cancel-all failed for %s: %s", name, exc) + + if cancelled: + refresh_state(SYS_ORDER) + return {"cancelled": cancelled, "skipped": skipped, "errors": errors} + + def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]: """Build pending result for a hash-based subsystem.""" if state is None: diff --git a/daemon/iface.py b/daemon/iface.py index 6c50f20..9c678a9 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -209,6 +209,7 @@ GET_WS: Endpoint = _ep("GET", "/ws") POST_BATCH: Endpoint = _ep("POST", "/batch") GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending") POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all") +POST_STATUS_CANCEL_ALL: Endpoint = _ep("POST", "/status/cancel-all") GET_SYSTEM_METRICS: Endpoint = _ep("GET", "/system/metrics") # Collect all endpoint module-level constants for __all__ verification diff --git a/docs/api.md b/docs/api.md index cb7759d..c4464b2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1962,7 +1962,8 @@ Aggregate pending changes across all subsystems. Useful for the dashboard to sho | Field | Type | Description | |-------|------|-------------| -| `subsystems` | `object` | Map of subsystem name to pending status | +| `firewall` | `object` | `{ needs_apply, change_count, changes: [{summary, detail}] }` | +| `dnsmasq` / `nginx` / `wireguard` / `networkd` | `object` | `{ pending_changes, summary, changes: [{summary, detail}] }` | | `total_changes` | `number` | Total count of pending changes across all subsystems | --- @@ -1984,6 +1985,31 @@ Apply pending changes for all subsystems in dependency order. --- +#### Cancel All Pending Changes + +``` +POST /api/status/cancel-all +``` + +Revert pending changes for all subsystems to the last applied +configuration. Restores each pending subsystem's `config.json` from its +recorded `_last_applied_config` snapshot, discarding unapplied edits. +Subsystems without a recorded baseline (config never applied) are +reported as skipped and left untouched. No live-system commands run — +only the declarative config files are written. + +**Request Body:** none. + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `cancelled` | `[string, ...]` | Subsystems reverted to their last applied config | +| `skipped` | `object` | Map of subsystem label → reason (e.g. "No baseline recorded (never applied)") | +| `errors` | `object` | Map of subsystem label → error message | + +--- + #### Refresh State ``` diff --git a/docs/config.md b/docs/config.md index 452f25b..cdf7b03 100644 --- a/docs/config.md +++ b/docs/config.md @@ -538,6 +538,8 @@ Both `/api/firewall/zones//services` and `/api/firewall/config/apply` reco **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//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. +**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel. + ## Networkd (IP Configuration) **File**: `config/network/config.json` diff --git a/docs/hoover.md b/docs/hoover.md index 5bf04b0..98e38d3 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -1172,6 +1172,39 @@ Table({ }) ``` +### Apply / Cancel + +`components/applyconfirm.js` — cross-subsystem apply/cancel buttons with a +shared expandable-subsystems modal. Both fetch `/api/status/pending` to +populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall, +dnsmasq, nginx, wireguard, networkd). + +#### `ApplyConfirm(props)` + +Button that opens the confirmation modal listing pending subsystems, then +POSTs `/api/status/apply-all`. When `props.pending` is false it renders a +disabled "synced" button that toasts on click. + +**Parameters:** `pending` (bool), `label`, `syncedLabel`, `cls`, +`successMsg`, `refresh` (legacy, ignored). + +#### `CancelConfirm(props)` + +Button that opens the confirmation modal listing the subsystems that +would be reverted ("Restores the listed subsystems to their last applied +configuration, discarding changes saved since the last apply"), then +POSTs `/api/status/cancel-all`. Success toast appends skipped-subsystem +details when the response has a non-empty `skipped` map; errors from the +response are toasted separately. State-store models update from the +daemon's WS delta — no explicit `modelFetch`. + +**Parameters:** `label` (default `'Cancel All Changes'`), `cls` +(default `'btn btn-danger'`). + +```javascript +CancelConfirm({ cls: 'btn btn-sm btn-danger' }) +``` + ### Modal #### `openModal(renderFn)` diff --git a/docs/state-model.md b/docs/state-model.md index e3e929d..d07bb9a 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -17,6 +17,17 @@ return annotation references them. - A subsystem whose collection failed holds `null`/`None` in the state store — WS snapshots and deltas skip `null` payloads so a failed collector never overwrites good client data. +- Config-backed subsystems record their applied baseline inside the config + file itself: `_last_applied_config` (the full merged config at last + apply) and `_last_applied_hash` (its SHA-256). A hash subsystem's + `status.pending_changes` is true when the current (merged) config hash + differs from the recorded hash; `status.pending_diff` lists the field + changes since that snapshot. All apply operations (including firewall + `config_apply`) re-stamp the baseline. These bookkeeping keys are + internal and stripped from every state/API config payload. Canceling + pending changes (`POST /api/status/cancel-all`) restores a pending + config file from its snapshot; a subsystem with no recorded baseline + (never applied) is reported as skipped, not reset. ## State shape summary diff --git a/lib/common.py b/lib/common.py index 54436f5..828eb0b 100644 --- a/lib/common.py +++ b/lib/common.py @@ -55,6 +55,29 @@ def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]: return cfg +def revert_to_applied(path: Path) -> tuple[bool, str]: + """Restore the config file at *path* to its last-applied snapshot. + + Reads the raw file, and when it records a ``_last_applied_config`` + snapshot, rewrites the file from that snapshot (stamped with a fresh + hash so the pending check reports the config as up to date). + + Args: + path: Path to the config JSON file to revert. + + Returns: + Tuple ``(True, "")`` when the file was restored, or + ``(False, reason)`` when it could not be (missing file or no + recorded baseline — i.e. the config was never applied). + """ + cfg = load_json(path) + snap = cfg.get(_LAST_APPLIED_CONFIG_KEY) + if not isinstance(snap, dict): + return False, "No baseline recorded (never applied)" + save_json(path, stamp_applied(deepcopy(snap))) + return True, "" + + def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]: """Return a list of field-level changes between two configurations. @@ -284,6 +307,7 @@ __all__ = [ "ensure_dirs", "get_interface_ip", "load_json", + "revert_to_applied", "run", "run_proc", "save_json", diff --git a/lib/state.py b/lib/state.py index d955586..e904d3f 100644 --- a/lib/state.py +++ b/lib/state.py @@ -517,12 +517,12 @@ def _collect_firewall() -> schema.FirewallState: except Exception: pass - # Load config + # 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 = load_json(fw_config_path) + config_data = strip_apply_meta(load_json(fw_config_path)) # Pending changes full_state = { diff --git a/tests/test-cancelconfirm.js b/tests/test-cancelconfirm.js new file mode 100644 index 0000000..00298e4 --- /dev/null +++ b/tests/test-cancelconfirm.js @@ -0,0 +1,87 @@ +/** + * Tests for hoover/components/applyconfirm.js — CancelConfirm + * + * Component-level tests: VNode structure of the cancel button. + * Run with `node tests/test-cancelconfirm.js`. + */ + +import { CancelConfirm, buildRows, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + passed++; + } catch (e) { + console.error(` \u2717 ${name}: ${e.message}`); + failed++; + } +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +function assertEq(a, b, msg) { + if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`); +} + +function assertIncludes(str, substr, msg) { + if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`); +} + +console.log('Testing CancelConfirm component\n'); + +test('CancelConfirm renders a button vnode', () => { + const vnode = CancelConfirm(); + assertEq(vnode.tag, 'button'); +}); + +test('CancelConfirm default label', () => { + const vnode = CancelConfirm(); + const text = vnode.ch.find(c => c.tag === '#text'); + assert(text, 'should have text child'); + assertEq(text.text, 'Cancel All Changes'); +}); + +test('CancelConfirm default class is danger', () => { + const vnode = CancelConfirm(); + assertIncludes(vnode.props.class, 'btn-danger'); + assertIncludes(vnode.props.class, 'btn'); +}); + +test('CancelConfirm accepts a custom class', () => { + const vnode = CancelConfirm({ cls: 'btn btn-sm btn-danger' }); + assertEq(vnode.props.class, 'btn btn-sm btn-danger'); +}); + +test('CancelConfirm accepts a custom label', () => { + const vnode = CancelConfirm({ label: 'Discard Changes' }); + const text = vnode.ch.find(c => c.tag === '#text'); + assertEq(text.text, 'Discard Changes'); +}); + +test('CancelConfirm has a click handler', () => { + const vnode = CancelConfirm(); + assert(typeof vnode.props['on:click'] === 'function', 'on:click should be a function'); +}); + +// === shared modal row builder (used by the cancel modal) === +test('buildRows still drives the cancel modal rows', () => { + const data = { + firewall: { needs_apply: true, changes: [{ summary: 'Zone internal: interfaces changed', detail: '' }] }, + dnsmasq: { pending_changes: true, changes: [{ summary: 'x', detail: '' }] }, + }; + const rows = buildRows(data, {}); + assertEq(rows.length, SUBSYSTEM_LIST.length); + const fwRow = rows[0]; + assertIncludes(fwRow.props.class, 'pending'); + const dmRow = rows[1]; + assertIncludes(dmRow.props.class, 'pending'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/test_api.py b/tests/test_api.py index 8ce9a7e..ef34bf9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -997,4 +997,24 @@ class TestStatusRefresh: mock_post.side_effect = RuntimeError("no daemon") resp = status_client.post("/api/status/refresh", json={}) assert resp.status_code == 500 + + +class TestStatusCancelAll: + @_st("post") + def test_success(self, mock_post, status_client): + from daemon.iface import POST_STATUS_CANCEL_ALL + + mock_post.return_value = {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}} + resp = status_client.post("/api/status/cancel-all") + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + assert data["data"] == {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}} + mock_post.assert_called_once_with(POST_STATUS_CANCEL_ALL) + + @_st("post") + def test_runtime_error(self, mock_post, status_client): + mock_post.side_effect = RuntimeError("no daemon") + resp = status_client.post("/api/status/cancel-all") + assert resp.status_code == 500 assert resp.get_json()["ok"] is False diff --git a/tests/test_common.py b/tests/test_common.py index a3c50cd..7e72a5e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -7,6 +7,9 @@ from lib.common import ( _LAST_APPLIED_CONFIG_KEY, config_hash, deep_diff, + load_json, + revert_to_applied, + save_json, stamp_applied, strip_apply_meta, ) @@ -82,3 +85,66 @@ class TestDashboardFallback: status = {"pending_changes": True, "pending_diff": []} assert status["pending_changes"] is True assert status["pending_diff"] == [] + + +class TestRevertToApplied: + def test_restores_snapshot_and_clears_pending(self, tmp_path): + path = tmp_path / "config.json" + applied = {"zones": {"lan": {"services": ["http"]}}} + stamped = dict(applied) + stamp_applied(stamped) + # Drift the file after apply (the "pending" state). + dirty = {"zones": {"lan": {"services": ["http", "ssh"]}}} + dirty[_LAST_APPLIED_CONFIG_KEY] = dict(applied) + dirty[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY] + save_json(path, dirty, indent=2) + # Sanity: pending check would report drift. + assert dirty[_APPLY_HASH_KEY] != config_hash(dirty) + + ok, reason = revert_to_applied(path) + assert ok and reason == "" + + restored = load_json(path) + assert _APPLY_HASH_KEY in restored and restored[_APPLY_HASH_KEY] == config_hash( + restored + ) + assert ( + _LAST_APPLIED_CONFIG_KEY in restored + and restored[_LAST_APPLIED_CONFIG_KEY] == applied + ) + assert ( + deep_diff( + restored.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(restored) + ) + == [] + ) + + def test_no_baseline(self, tmp_path): + path = tmp_path / "config.json" + save_json(path, {"zones": {}}, indent=2) + ok, reason = revert_to_applied(path) + assert not ok + assert reason + # File untouched. + assert _LAST_APPLIED_CONFIG_KEY not in load_json(path) + + def test_missing_file(self, tmp_path): + ok, reason = revert_to_applied(tmp_path / "nope.json") + assert not ok + assert reason + + def test_stale_hash_but_snapshot_present(self, tmp_path): + # Baseline recorded, hash stale (drifted) → still revertable. + path = tmp_path / "config.json" + applied = {"a": 1} + stamped = dict(applied) + stamp_applied(stamped) + stamp = dict(stamped) + stamp["a"] = 99 # edited without re-stamping + save_json(path, stamp, indent=2) + assert stamp[_APPLY_HASH_KEY] != config_hash(stamp) + + ok, _ = revert_to_applied(path) + assert ok + restored = load_json(path) + assert restored[_APPLY_HASH_KEY] == config_hash(restored) diff --git a/tests/test_firewall.py b/tests/test_firewall.py index d02d5dd..41f5a30 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -1,5 +1,6 @@ """Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary).""" +from copy import deepcopy from unittest.mock import MagicMock, call, patch import pytest @@ -7,6 +8,12 @@ import pytest from daemon.handlers import firewall as daemonfirewall from daemon.server import ConflictError, NotFoundError from lib import firewall +from lib.common import ( + _APPLY_HASH_KEY, + _LAST_APPLIED_CONFIG_KEY, + config_hash, + strip_apply_meta, +) # --------------------------------------------------------------------------- # lib/firewall.py — pure parsing (no sudo) @@ -531,6 +538,11 @@ class TestDaemonConfigApply: return_value={"zones": {"public": {}}}, ), patch("daemon.handlers.firewall.refresh_state"), + patch( + "daemon.handlers.firewall._get_config", + return_value={"zones": {"public": {}}}, + ), + patch("daemon.handlers.firewall._save_config"), ): result = daemonfirewall._config_apply() assert result["applied_zones"] == ["public"] @@ -661,10 +673,87 @@ class TestDaemonMgmtLockoutGuard: return_value={"zones": {"public": {}}}, ), patch("daemon.handlers.firewall.refresh_state"), + patch( + "daemon.handlers.firewall._get_config", + return_value={"zones": {"public": {}}}, + ), + patch("daemon.handlers.firewall._save_config"), ): result = daemonfirewall._config_apply(force=True) assert result["applied_zones"] == ["public"] + +_STAMP_TEST_CFG = { + "zones": { + "public": { + "target": "DEFAULT", + "interfaces": ["eth0"], + "services": ["http"], + "masquerade": False, + }, + }, +} + + +class TestDaemonConfigApplyStamp: + """Verify _config_apply records the applied baseline in the config file.""" + + ZONE_LIST_ALL_OUT = ( + "target: default\ninterfaces: \nsources: \nservices: \nports: \n" + "protocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \n" + "icmp-blocks: \nmodule: \n" + ) + + @patch( + "lib.firewall.get_config", + return_value=_STAMP_TEST_CFG, + create=True, + ) + @patch( + "daemon.handlers.firewall.run", + return_value=ZONE_LIST_ALL_OUT, + ) + def test_stamps_applied_baseline(self, mock_run, mock_cfg): + with ( + patch( + "daemon.handlers.firewall._save_backup", + return_value="/tmp/rules.json", + ), + patch( + "daemon.handlers.firewall._get_state", + return_value={"zones": {"public": {}}}, + ), + patch("daemon.handlers.firewall.refresh_state"), + patch( + "daemon.handlers.firewall._get_config", + return_value=deepcopy(_STAMP_TEST_CFG), + ), + patch("daemon.handlers.firewall._save_config") as mock_save, + ): + result = daemonfirewall._config_apply() + + assert result["applied_zones"] == ["public"] + saved = mock_save.call_args[0][0] + assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved) + assert saved[_APPLY_HASH_KEY] == config_hash(saved) + # The snapshot is the applied (meta-stripped) config. + assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG + + +class TestDaemonGetConfigEndpoint: + def test_strips_apply_meta(self): + with patch.object( + daemonfirewall, + "_get_config", + return_value={ + "zones": {}, + _APPLY_HASH_KEY: "h", + _LAST_APPLIED_CONFIG_KEY: {"zones": {}}, + }, + ): + result = daemonfirewall.get_config(None, None) + assert result == {"zones": {}} + @patch( "daemon.handlers.firewall._config_apply", return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"}, diff --git a/tests/test_status_cancel.py b/tests/test_status_cancel.py new file mode 100644 index 0000000..29a486c --- /dev/null +++ b/tests/test_status_cancel.py @@ -0,0 +1,221 @@ +"""Tests for daemon/handlers/status.py — cancel-all (revert to applied).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, ClassVar +from unittest.mock import patch + +from daemon.handlers import status +from lib.common import ( + _APPLY_HASH_KEY, + _LAST_APPLIED_CONFIG_KEY, + config_hash, + stamp_applied, +) + + +def _pending(firewall: bool = False, **flags: bool) -> dict[str, Any]: + """Build status_pending() output with the given subsystems pending. + + Mirrors the real handler shape: every subsystem key is always present. + """ + data: dict[str, Any] = { + "firewall": { + "needs_apply": firewall, + "change_count": 1 if firewall else 0, + "changes": [], + }, + } + total = 1 if firewall else 0 + for name in ("dnsmasq", "nginx", "wireguard", "networkd"): + flagged = bool(flags.get(name, False)) + data[name] = { + "pending_changes": flagged, + "summary": "x" if flagged else "Up to date", + "changes": [], + } + total += 1 if flagged else 0 + data["total_changes"] = total + return data + + +class TestConfigPathResolution: + """SYS_CONFIG_PATHS points at the constants the handlers actually use.""" + + def test_resolves_to_module_constants(self): + import daemon.handlers.dnsmasq as dm_h + import daemon.handlers.firewall as fw_h + import daemon.handlers.nginx as ngx_h + import lib.network as net_lib + import lib.wireguard as wg_lib + + assert status._config_path("firewall") == fw_h.CONFIG_FILE + assert status._config_path("dnsmasq") == dm_h.CONFIG_PATH + assert status._config_path("nginx") == ngx_h.CONFIG_FILE + assert status._config_path("wireguard") == wg_lib.CONFIG_PATH + assert status._config_path("networkd") == net_lib.CONFIG_FILE + + def test_all_subsystems_mapped(self): + assert set(status.SYS_CONFIG_PATHS) == set(status.SYS_ORDER) + + +class TestStatusCancelAll: + """Test the cancel-all endpoint. + + Patches the ``_config_path`` seam directly since it resolves the real + module constants via getattr at call time. + """ + + _fake_pending_all: ClassVar[dict[str, Any]] = _pending() + + def _write_config( + self, path: Path, cfg: dict[str, Any], baseline: dict | None + ) -> None: + """Write *cfg* (with an attached applied baseline when *baseline* given).""" + path.parent.mkdir(parents=True, exist_ok=True) + stamped = dict(cfg) + if baseline is not None: + b = dict(baseline) + stamp_applied(b) + stamped[_LAST_APPLIED_CONFIG_KEY] = dict(baseline) + stamped[_APPLY_HASH_KEY] = b[_APPLY_HASH_KEY] + path.write_text(json.dumps(stamped, indent=2) + "\n") + + def _read_config(self, path: Path) -> dict[str, Any]: + return json.loads(path.read_text()) + + @patch("daemon.handlers.status.status_pending") + @patch("daemon.handlers.status.refresh_state") + def test_nothing_pending(self, mock_refresh, mock_pending): + mock_pending.return_value = self._fake_pending_all + with patch("daemon.handlers.status._config_path", side_effect=AssertionError): + result = status.status_cancel_all(None, None) + assert result == {"cancelled": [], "skipped": {}, "errors": {}} + mock_refresh.assert_not_called() + + def test_reverts_pending_subsystem(self, tmp_path): + paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} + applied = {"dns": {"upstreams": ["8.8.8.8"]}} + # Baseline applied, then the user edited the config (drift). + self._write_config( + paths["dnsmasq"], {"dns": {"upstreams": ["1.1.1.1"]}}, applied + ) + + dirty = self._read_config(paths["dnsmasq"]) + assert dirty[_APPLY_HASH_KEY] != config_hash(dirty) # really pending + + with ( + patch( + "daemon.handlers.status.status_pending", + return_value=_pending(dnsmasq=True), + ), + patch("daemon.handlers.status.refresh_state") as mock_refresh, + patch( + "daemon.handlers.status._config_path", + side_effect=lambda name: paths[name], + ), + ): + result = status.status_cancel_all(None, None) + + assert result == { + "cancelled": ["dnsmasq"], + "skipped": {}, + "errors": {}, + } + mock_refresh.assert_called_once_with(status.SYS_ORDER) + + restored = self._read_config(paths["dnsmasq"]) + assert restored.get(_LAST_APPLIED_CONFIG_KEY) == applied + # Cancel restored the baseline: the pending check now passes. + assert restored[_APPLY_HASH_KEY] == config_hash(restored) + + def test_skipped_when_no_baseline(self, tmp_path): + paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} + cfg_no_baseline = {"peers": {"a": {}}} + self._write_config(paths["wireguard"], cfg_no_baseline, None) + + with ( + patch( + "daemon.handlers.status.status_pending", + return_value=_pending(wireguard=True), + ), + patch("daemon.handlers.status.refresh_state") as mock_refresh, + patch( + "daemon.handlers.status._config_path", + side_effect=lambda name: paths[name], + ), + ): + result = status.status_cancel_all(None, None) + + assert result["cancelled"] == [] + assert "WireGuard" in result["skipped"] + assert result["errors"] == {} + mock_refresh.assert_not_called() + # File left untouched. + assert self._read_config(paths["wireguard"]) == cfg_no_baseline + + def test_error_in_one_subsystem_others_still_cancel(self, tmp_path): + paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} + for name in ("firewall", "nginx"): + self._write_config( + paths[name], + {"zones": {}} if name == "firewall" else {"domains": {}}, + {"x": 1}, + ) + + def fake_revert(path): + if "nginx" in str(path): + raise RuntimeError("disk full") + return True, "" + + with ( + patch( + "daemon.handlers.status.status_pending", + return_value=_pending(firewall=True, nginx=True), + ), + patch("daemon.handlers.status.refresh_state"), + patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert), + patch( + "daemon.handlers.status._config_path", + side_effect=lambda name: paths[name], + ), + ): + result = status.status_cancel_all(None, None) + + # SYS_ORDER runs firewall before nginx. + assert result["cancelled"] == ["firewall"] + assert result["errors"] == {"Nginx": "disk full"} + assert result["skipped"] == {} + + def test_order_matches_sys_order(self, tmp_path): + paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} + call_order = [] + + def fake_revert(path): + call_order.append(next(n for n, p in paths.items() if p == path)) + return True, "" + + with ( + patch( + "daemon.handlers.status.status_pending", + return_value=_pending( + firewall=True, + dnsmasq=True, + nginx=True, + wireguard=True, + networkd=True, + ), + ), + patch("daemon.handlers.status.refresh_state"), + patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert), + patch( + "daemon.handlers.status._config_path", + side_effect=lambda name: paths[name], + ), + ): + result = status.status_cancel_all(None, None) + + assert result["cancelled"] == status.SYS_ORDER + assert call_order == status.SYS_ORDER diff --git a/webui/api/status.py b/webui/api/status.py index 79deda1..141e97d 100644 --- a/webui/api/status.py +++ b/webui/api/status.py @@ -14,6 +14,7 @@ from daemon.iface import ( GET_STATUS_PENDING, GET_SYSTEM_METRICS, POST_STATUS_APPLY_ALL, + POST_STATUS_CANCEL_ALL, POST_STATUS_REFRESH, ) from webui.api.common import _error, _ok @@ -56,6 +57,24 @@ def apply_all(): return _error(str(exc), 500) +@bp.route("/cancel-all", methods=["POST"]) +def cancel_all(): + """Revert pending changes for all subsystems to the 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"]) def refresh(): """Re-collect state from the daemon, optionally filtered by subsystem. diff --git a/webui/static/hoover/components/applyconfirm.js b/webui/static/hoover/components/applyconfirm.js index 595f713..e9ecc57 100644 --- a/webui/static/hoover/components/applyconfirm.js +++ b/webui/static/hoover/components/applyconfirm.js @@ -140,3 +140,91 @@ export function ApplyConfirm(props = {}) { }, }, props.pending ? label : syncedLabel); } + +/** + * POST cancel-all, toast result, close modal. State-store models update from + * the daemon's WS delta — no explicit refresh. + */ +async function doCancelAll() { + if (isModalProcessing()) return; + setModalProcessing(true); + try { + const resp = await apiFetch('/api/status/cancel-all', { method: 'POST' }); + if (resp.ok) { + const data = resp.data || {}; + let msg = 'Pending changes cancelled'; + const nSkipped = Object.keys(data.skipped || {}).length; + if (nSkipped) { + msg += ` (${nSkipped} skipped: ` + + Object.entries(data.skipped).map(([k, v]) => `${k} — ${v}`).join('; ') + ')'; + } + toast(msg, nSkipped ? 'warning' : 'success', nSkipped ? 8000 : undefined); + const errs = data.errors || {}; + const nErrs = Object.keys(errs).length; + if (nErrs) { + toast('Cancel failed for: ' + + Object.entries(errs).map(([k, v]) => `${k} — ${v}`).join('; '), + 'error', 8000); + } + closeModal(); + // No modelFetch — WS delta updates all affected subsystems. + } else { + toast(resp.error || 'Cancel failed', 'error'); + } + } finally { + setModalProcessing(false); + refreshModals(); + } +} + +/** + * Fetch pending state, then open the cancel confirmation modal. + */ +async function openCancelModal() { + const pendingResp = await apiFetch('/api/status/pending'); + if (!pendingResp.ok) { + toast(pendingResp.error || 'Could not fetch pending changes', 'error'); + return; + } + + const pendingData = pendingResp.data || {}; + const totalChanges = pendingData.total_changes || 0; + + const expanded = reactive({}); + + openModal((inner) => { + if (totalChanges === 0) { + modalVNodes(inner, html`
+ +
No pending changes to cancel.
+
+
`); + return; + } + + const rows = buildRows(pendingData, expanded); + + modalVNodes(inner, html`
+ +

Restores the listed subsystems to their last applied configuration, discarding changes saved since the last apply.

+ +
+
`); + }); +} + +/** + * Cancel button with cross-subsystem confirmation modal. + * + * @param {object} props + * @param {string} [props.label] - Button text (default: 'Cancel All Changes') + * @param {string} [props.cls] - Button CSS classes (default: 'btn btn-danger') + */ +export function CancelConfirm(props = {}) { + const label = props.label || 'Cancel All Changes'; + + return h('button', { + class: props.cls !== undefined ? props.cls : 'btn btn-danger', + 'on:click': () => openCancelModal(), + }, label); +} diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index c4a6dc3..5db9bc6 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -53,7 +53,7 @@ export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, cert export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js'; /* ── UI Components: Apply ────────────────────────────────────── */ -export { ApplyConfirm } from './components/applyconfirm.js'; +export { ApplyConfirm, CancelConfirm } from './components/applyconfirm.js'; /* ── UI Components: Toast ────────────────────────────────────── */ export { ToastContainer } from './components/toast.js'; diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index 0fdf2c8..bf4697b 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -1,4 +1,4 @@ -import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, fmtBytes } from '/static/hoover/index.js'; +import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, CancelConfirm, fmtBytes } from '/static/hoover/index.js'; // Render a single firewall change as "current → new". // `live` is the currently applied value; `config` is the target value it @@ -144,7 +144,7 @@ export default definePage({ meta=${expiringCerts.length ? expiringCerts.length + ' expiring' : 'All valid'} /> `; - // ── Pending changes ── + // ── Pending changes (hidden entirely when nothing is pending) ── const pendingCard = pendingBlocks.length > 0 ? html`
Pending Changes <${Badge} text=${String(totalChanges)} variant="warning" />
@@ -157,15 +157,15 @@ export default definePage({ `)} - <${ActionButton} url="/api/status/apply-all" label="Apply All Changes" - successMsg="All changes applied" - cls="btn btn-sm btn-primary" /> +
+ <${ActionButton} url="/api/status/apply-all" label="Apply All Changes" + successMsg="All changes applied" + cls="btn btn-sm btn-primary" /> + <${CancelConfirm} cls="btn btn-sm btn-danger" /> +
` - : html`
-
Pending Changes
-
<${Badge} text="All configured" variant="success" />
-
`; + : null; // ── System resources ── const memPct = sysMem.used_pct || 0;