fix: install.sh loop abort, ACME poll sudo gate, /static/ sub-paths

- install.sh: the traversal-chmod loop assigned _d but looped over the
  never-set $d; under set -u every fresh install aborted with
  "d: unbound variable" at that line. Loop over $_d.
- acme collector: the self-heal normalize (sudo chmod g+rwX) now runs
  only when a no-sudo group-read-bit probe detects a lost bit — acme.sh
  re-hardens the tree 600 on every run, so the steady-state poll makes
  no sudo call. The group bit (not daemon readability) is what the
  two-user model keeps for the WebUI user.
- lib.acme: new get_acme_home() accessor (ACME_HOME env, default
  data/acme), reused by _run_acme; _summarize_acme_output preserves a
  "Permission denied" line even when it is not among the final two, so
  the collector's actionable-error matcher keeps firing.
- nginx template: emit location /static/ for any is_management path
  (not only '/'); the SPA references /static/... at the domain root
  regardless of the management backend path.
- tests: probe, summarizer, and nginx-subpath cases in
  test_state.py, test_acme.py, test_nginx.py.
This commit is contained in:
2026-09-05 00:38:34 +00:00
parent 6229c39347
commit 78fcb01877
7 changed files with 182 additions and 17 deletions
+31 -8
View File
@@ -3,9 +3,11 @@
import logging
import os
from pathlib import Path
from stat import S_IRGRP
from typing import Any
from lib import schema
from lib.acme import get_acme_home
from lib.common import load_json
from lib.state import PROJECT_DIR, _now_iso, register_collector
@@ -157,6 +159,25 @@ def _friendly_acme_error(exc: Exception) -> str:
return text
def _acme_home_needs_normalize() -> bool:
"""Cheap no-sudo probe: has any ACME_HOME file lost its group-read bit?
acme.sh re-hardens its tree (``chmod 600``) on every run, so the daemon's
self-heal (``normalize_acme_home``) is only needed after a run by another
user (e.g. a manual run as the WebUI user) stripped group read. The probe
checks the group bit — not the daemon's own readability — because group
read is what the two-user model keeps for the WebUI user; a file the
daemon can read but the group cannot must still be healed.
"""
try:
for p in get_acme_home().rglob("*"):
if p.is_file() and not (p.stat().st_mode & S_IRGRP):
return True
except OSError:
return True
return False
def _collect_acme() -> schema.AcmeState:
"""Collect ACME certificate list and email.
@@ -171,17 +192,19 @@ def _collect_acme() -> schema.AcmeState:
# `status.error` so the poll diff still detects recovery.
cert_error: str | None = None
try:
# Self-heal ACME_HOME permissions before listing, exactly like the
# handler preflight (_run_acme_preflight). acme.sh dot-sources
# account.conf on startup; a prior run by another user (e.g. a manual
# run as the WebUI user) can leave it owner-only and make `--list`
# exit 2. The startup normalize only covers the first collection, so
# the poll must normalize too or a mid-lifetime ownership flip would
# blank the cert list until the next issue/renew or daemon restart.
# Self-heal ACME_HOME permissions before listing, but only when the
# probe detects a lost group-read bit — the steady-state poll then
# makes no sudo call. acme.sh dot-sources account.conf on startup; a
# prior run by another user (e.g. a manual run as the WebUI user) can
# leave it owner-only and make `--list` exit 2. The startup normalize
# only covers the first collection, so the poll must probe too or a
# mid-lifetime ownership flip would blank the cert list until the
# next issue/renew or daemon restart.
from daemon.handlers.acme import normalize_acme_home
from lib.acme import list_certs
normalize_acme_home()
if _acme_home_needs_normalize():
normalize_acme_home()
certs = list_certs()
except Exception as exc:
logger.warning("ACME state collection failed", exc_info=True)