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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user