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:
+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