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
+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
elif "interfaces" in old_zone_cfg:
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)
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()
# 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.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
stamp_applied(cfg)
_save_config(cfg)
logger.info("Zone '%s' services set to %s", zone, services)
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]
entry = {"id": rule_id, "rule": rule}
cfg["zones"][zone]["rich_rules"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
@@ -1035,6 +1042,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
zone_cfg["rich_rules"] = [
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
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]:
"""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:
_request: The incoming HTTP request (unused).
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"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_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(
SyncEvent(
"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()
fp_id = uuid4().hex[:8]
entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto}
if toaddr:
if toaddr and toport:
entry["toaddr"] = toaddr
if toport:
entry["toport"] = int(toport)
cfg = _get_config()
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
cfg["zones"][zone]["forward_ports"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
@@ -1233,6 +1256,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
cfg["zones"][zone]["forward_ports"] = [
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(