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
+4
View File
@@ -95,6 +95,10 @@ def _run_acme(args: list[str]) -> str:
acme_home_env,
"--config-home",
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,
]
+7 -1
View File
@@ -181,12 +181,18 @@ def get_config() -> dict[str, Any]:
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
save_config(raw)
return raw
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw:
raw["backends"] = {}
pre = deepcopy(raw)
raw = _migrate_config(raw)
save_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)
return raw
+4 -1
View File
@@ -280,15 +280,18 @@ class AcmeState(TypedDict):
"""ACME state (collector: `_collect_acme`).
Attributes:
certs: Certificate list.
certs: Certificate list (empty when collection failed).
email: Registered ACME email.
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.
"""
certs: list[AcmeCert]
email: str
account: AcmeAccount
status: dict[str, str | None]
timestamp: str
+10 -6
View File
@@ -914,16 +914,19 @@ def _collect_acme() -> schema.AcmeState:
"""
email = _get_acme_email()
# Non-fatal: a broken acme.sh (e.g. unreadable account.conf after an
# ownership flip) must not blank the whole dashboard via a cleared
# state store. Collect what we can and surface the failure in
# `status.error` so the poll diff still detects recovery.
cert_error: str | None = None
try:
from lib.acme import list_certs
certs = list_certs()
except Exception:
logger.warning(
"ACME state collection failed, returning empty cert list",
exc_info=True,
)
raise
except Exception as exc:
logger.warning("ACME state collection failed", exc_info=True)
certs = []
cert_error = str(exc)
account = _parse_account_conf()
@@ -931,6 +934,7 @@ def _collect_acme() -> schema.AcmeState:
"certs": certs,
"email": email,
"account": account,
"status": {"error": cert_error},
"timestamp": _now_iso(),
}
+34 -4
View File
@@ -8,10 +8,18 @@ caused by install.sh or manual edits to system files.
import contextlib
import logging
import re
from copy import deepcopy
from pathlib import Path
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
logger = logging.getLogger(__name__)
@@ -53,6 +61,24 @@ def import_all() -> list[str]:
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
# ------------------------------------------------------------------
@@ -86,12 +112,13 @@ def import_dnsmasq() -> bool:
return False
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
existing: dict[str, Any] = {}
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping dnsmasq: config already matches")
return False
_carry_apply_meta(cfg, existing)
save_json(cfg_path, cfg)
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)
@@ -247,12 +274,13 @@ def import_wireguard() -> bool:
return False
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
existing: dict[str, Any] = {}
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping wireguard: config already matches")
return False
_carry_apply_meta(cfg, existing)
save_json(cfg_path, cfg)
peer_count = len(cfg.get("peers", {}))
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")
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(
"Imported firewall config: zones=%s",
", ".join(zone_configs.keys()),