fix: ACME ownership self-heal + daily timer, apply-all force, firewall baseline re-stamp

acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
  broke the two-user model: a tree left owner-only by one user made
  every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
  files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
  is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
  transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
  validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
  status.error (AcmeState.status) so the certs page can surface it

firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport

status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm

system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes

nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)

install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership

daemon/server: loop.get_exception_handler() (aiohttp API fix)

tests: 888 pytest + 24 node passing; ruff clean
This commit is contained in:
2026-09-01 02:35:04 +00:00
parent ac52918df5
commit 75b86fd60d
30 changed files with 738 additions and 53 deletions
+59 -8
View File
@@ -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"] = {}
@@ -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"
+26 -2
View File
@@ -856,6 +856,9 @@ 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)
@@ -927,9 +930,12 @@ 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( sync_result = bus.emit(
@@ -979,6 +985,7 @@ 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( sync_result = bus.emit(
SyncEvent( SyncEvent(
@@ -1035,6 +1042,7 @@ 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( sync_result = bus.emit(
SyncEvent( SyncEvent(
@@ -1084,6 +1092,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,6 +1120,16 @@ 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()
# Keep the declarative config in sync with the live zone so the pending
# diff and the cancel-all baseline stay consistent. Only touch zones that
# already exist in the config — creating a bare zone entry would
# manufacture spurious service/interface diffs on the next poll.
cfg = _get_config()
zone_cfg = cfg.get("zones", {}).get(zone)
if isinstance(zone_cfg, dict):
zone_cfg["masquerade"] = bool(enable)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent( SyncEvent(
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone} "firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
@@ -1161,13 +1183,14 @@ 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( sync_result = bus.emit(
SyncEvent( SyncEvent(
@@ -1233,6 +1256,7 @@ 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( sync_result = bus.emit(
SyncEvent( SyncEvent(
+12 -1
View File
@@ -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)
+12 -1
View File
@@ -698,7 +698,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 +747,17 @@ def main() -> None:
if reconciled: if reconciled:
logger.info("Reconciled subsystems: %s", ", ".join(reconciled)) logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
# 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()
+17 -4
View File
@@ -1976,6 +1976,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 and interface-coverage guards. Other subsystems
ignore it.
**Response (`data`):** **Response (`data`):**
| Field | Type | Description | | Field | Type | Description |
@@ -1983,10 +1993,13 @@ 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 refuses if an interface would be left without zone coverage (the
coverage guard) or both https/ssh would be stripped from the default zone
(lockout guard); the `ConflictError` surfaces in `errors` under
`"Firewall"` while the other subsystems proceed.
--- ---
+4
View File
@@ -95,6 +95,10 @@ def _run_acme(args: list[str]) -> str:
acme_home_env, acme_home_env,
"--config-home", "--config-home",
acme_home_env, acme_home_env,
# 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.
"--log",
*args, *args,
] ]
+6
View File
@@ -181,11 +181,17 @@ def get_config() -> dict[str, Any]:
raw = load_json(CONFIG_FILE) raw = load_json(CONFIG_FILE)
if not raw: if not raw:
raw = deepcopy(DEFAULT_CONFIG) raw = deepcopy(DEFAULT_CONFIG)
save_config(raw)
return raw
if "ssl" not in raw: if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL) raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw: if "backends" not in raw:
raw["backends"] = {} raw["backends"] = {}
pre = deepcopy(raw)
raw = _migrate_config(raw) raw = _migrate_config(raw)
# Read-only unless normalization/migration actually changed the config;
# re-saving on every read rewrites the file (owner/mtime churn).
if raw != pre:
save_config(raw) save_config(raw)
return raw return raw
+4 -1
View File
@@ -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
+10 -6
View File
@@ -914,16 +914,19 @@ def _collect_acme() -> schema.AcmeState:
""" """
email = _get_acme_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: try:
from lib.acme import list_certs from lib.acme import list_certs
certs = list_certs() certs = list_certs()
except Exception: except Exception as exc:
logger.warning( logger.warning("ACME state collection failed", exc_info=True)
"ACME state collection failed, returning empty cert list", certs = []
exc_info=True, cert_error = str(exc)
)
raise
account = _parse_account_conf() account = _parse_account_conf()
@@ -931,6 +934,7 @@ def _collect_acme() -> schema.AcmeState:
"certs": certs, "certs": certs,
"email": email, "email": email,
"account": account, "account": account,
"status": {"error": cert_error},
"timestamp": _now_iso(), "timestamp": _now_iso(),
} }
+34 -4
View File
@@ -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()),
+26
View File
@@ -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"
+8
View File
@@ -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/*
+9 -2
View File
@@ -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
+5 -1
View File
@@ -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
+6
View File
@@ -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
+34 -1
View File
@@ -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);
+91
View File
@@ -1099,6 +1099,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(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.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(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.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(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.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(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.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(
+72
View File
@@ -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
+23 -5
View File
@@ -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"),
+24
View File
@@ -55,6 +55,30 @@ 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_saves_when_migration_applied(self, temp_data_dir):
"""get_config() persists the file when migration actually changes it."""
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.
assert cfg["backends"]["webui"]["_migrated"] is True
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
class TestSaveConfig: class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir): def test_saves_and_reloads(self, temp_data_dir):
+1
View File
@@ -100,6 +100,7 @@ class TestCollectorShapesMatchSchema:
result = lib.state._collect_acme() result = lib.state._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(lib.state, "run_proc") as mock_proc:
+38
View File
@@ -3,6 +3,7 @@
import json import json
from unittest.mock import patch from unittest.mock import patch
import lib
from lib.state import State, state from lib.state import State, state
@@ -249,6 +250,43 @@ 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 lib.state import _collect_acme
with (
patch.object(lib.state, "_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(lib.state, "_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 lib.state import _collect_acme
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch("lib.acme.list_certs", return_value=[]),
patch.object(lib.state, "_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()
+39
View File
@@ -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
View File
@@ -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": [
+84 -1
View File
@@ -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()
+5 -1
View File
@@ -46,12 +46,16 @@ def apply_all():
Endpoint: Endpoint:
POST /api/status/apply-all POST /api/status/apply-all
Body:
{"force": true} (optional) overrides the firewall safety guards
(management lockout, interface coverage) for this apply.
Returns: Returns:
JSON response with applied subsystems list and any errors encountered. JSON response with applied subsystems list and any errors encountered.
""" """
body = request.get_json(silent=True)
try: try:
return _ok(post(POST_STATUS_APPLY_ALL)) return _ok(post(POST_STATUS_APPLY_ALL, body))
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to apply all pending changes: %s", exc) logger.error("Failed to apply all pending changes: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
+51 -4
View File
@@ -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. leaving an interface in no zone, 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>`);
}); });
} }
+9
View File
@@ -171,6 +171,14 @@ 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) {
// Batch endpoints (e.g. /api/status/apply-all) return 200
// with an `errors` map when some operations failed —
// `resp.ok` alone is not a success signal.
const errs = (resp.data && typeof resp.data.errors === 'object') ? resp.data.errors : null;
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; const synced = resp.data?.synced;
let msg = props.successMsg || ''; let msg = props.successMsg || '';
if (synced && synced.length) { if (synced && synced.length) {
@@ -178,6 +186,7 @@ export function ActionButton(props = {}) {
msg += '(auto-synced: ' + synced.join(', ') + ')'; 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 {
+10
View File
@@ -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'],
+2 -2
View File
@@ -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" />