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
+17 -3
View File
@@ -32,6 +32,11 @@ _ACME_ENVIRON = {
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
def get_acme_home() -> Path:
"""Resolve the ACME home directory (``ACME_HOME`` env, default ``data/acme``)."""
return Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
def _find_acme() -> str:
"""Locate the acme.sh binary on the system.
@@ -87,7 +92,7 @@ def _run_acme(args: list[str]) -> str:
acme_bin = _find_acme()
# Check for ACME_HOME env var (set by systemd in production)
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
acme_home_env = str(get_acme_home())
cmd: list[str] = [
acme_bin,
@@ -143,14 +148,22 @@ def _summarize_acme_output(output: str) -> str:
in the final lines (e.g. "The retryafter=86400 value is too large
(> 600), will not retry anymore."). Strips per-line timestamps and
the "Please check log file" pointer so the summary stays toast-
sized. The full transcript remains in the log and acme.sh.log.
sized. A "Permission denied" diagnostic is preserved even when it
is not among the final lines — the actionable-error matcher in
daemon/collectors/acme.py keys off it. The full transcript remains
in the log and acme.sh.log.
"""
lines = [line.strip() for line in output.strip().splitlines() if line.strip()]
lines = [re.sub(r"^\[[^\]]*\] ", "", line) for line in lines]
lines = [line for line in lines if not line.startswith("Please check log file")]
if not lines:
return "(no output)"
return "; ".join(lines[-2:])
tail = list(lines[-2:])
for line in reversed(lines):
if "Permission denied" in line and line not in tail:
tail.insert(0, line)
break
return "; ".join(tail)
def set_email(email: str) -> None:
@@ -649,6 +662,7 @@ __all__ = [
"days_until_expiry",
"deploy",
"find_cert_dir",
"get_acme_home",
"get_cert_info",
"get_cert_paths",
"get_email",