Files
mteehan 78fcb01877 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.
2026-09-05 00:38:34 +00:00

226 lines
7.9 KiB
Python

"""ACME state collector."""
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
logger = logging.getLogger(__name__)
_CA_NAME_MAP: dict[str, str] = {
"letsencrypt": "Let's Encrypt",
"zerossl": "ZeroSSL",
}
def _resolve_ca_name(ca_server: str) -> str:
"""Map a CA server identifier to its human-readable name.
Uses prefix matching sorted by longest prefix first to avoid
shorter prefixes winning (e.g. "letsencrypt" matching before
"letsencrypt.org").
Args:
ca_server: Raw CA server string from acme.sh config.
Returns:
Human-readable name, or unchanged string if no match.
"""
for prefix, name in sorted(
_CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
):
if ca_server.startswith(prefix):
return name
return ca_server
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"""Parse acme.sh account information and return account status dict.
Checks three sources in order:
1. Legacy ``.account.conf`` file (acme.sh v2.x format)
2. Declarative ``config/acme/config.json`` (saved by the registration
handler with ``email`` and ``ca`` fields)
Args:
acme_home: Optional override for ACME home directory. Falls back
to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``.
Returns:
Dict with ``registered``, ``email``, ``ca``, and
``key_length`` keys. If no account is found, ``registered`` is
``False`` with empty / ``None`` values.
"""
if acme_home is None:
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
default = {
"registered": False,
"email": "",
"ca": "",
"key_length": None,
}
# 1. acme.sh account file. Modern acme.sh (v3.x) writes ``account.conf``;
# older v2.x wrote ``.account.conf``. Check both so the account card
# reflects the real acme.sh account rather than only the declarative
# fallback below.
account_path = None
for name in ("account.conf", ".account.conf"):
candidate = acme_home / name
if candidate.is_file():
account_path = candidate
break
if account_path is not None:
try:
text = account_path.read_text()
except OSError:
pass
else:
email = ""
ca_raw = ""
key_length = None
for line in text.splitlines():
if line.startswith("ACME_LEEMAIL="):
email = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_MCA="):
ca_raw = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_CERTKEYSIZE="):
raw_val = line.split("=", 1)[1].strip().strip("'\"")
key_length = int(raw_val) if raw_val.isdigit() else None
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": key_length,
}
# 2. Declarative config (saved by register_account / set_email handlers)
# Modern acme.sh (v3.x) stores account data in per-CA JSON files
# (ca/<server>/account.json) — we can't reliably parse those without
# walking the directory, so fall back to the declarative config
# which the handlers keep in sync.
# Derive project root from acme_home (acme_home is at <root>/data/acme).
try:
project_root = acme_home.parent.parent # data/acme → data → project root
acme_cfg = project_root / "config" / "acme" / "config.json"
data = load_json(acme_cfg)
email = (data.get("email") or "").strip()
ca_raw = (data.get("ca") or "").strip()
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": None,
}
except (OSError, ValueError):
pass
return default
def _get_acme_email() -> str:
"""Read the ACME ``acme.sh`` email from the account config file.
Falls back to the declarative ACME config (config/acme/config.json)
if acme.sh account has not been registered yet.
"""
from lib.acme import _read_acme_email
return _read_acme_email()
def _friendly_acme_error(exc: Exception) -> str:
"""Turn a collection exception into an actionable message.
The collector already self-heals by normalizing ACME_HOME permissions
first, so the one remaining permission case is when that normalize could
not run (e.g. the sudo step was denied). For that case surface a concrete
remediation instead of the raw acme.sh exit-2 text; otherwise return the
original message unchanged.
"""
text = str(exc)
if "account.conf" in text and "Permission denied" in text:
return (
f"{text} — account.conf is not readable by the daemon; repair it "
"with: sudo chown <daemon-user>:<group> <ACME_HOME>/account.conf "
"&& sudo chmod 0640 <ACME_HOME>/account.conf, then restart "
"vacuum-walld"
)
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.
Returns:
Dict containing certificate details and registered email.
"""
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:
# 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
if _acme_home_needs_normalize():
normalize_acme_home()
certs = list_certs()
except Exception as exc:
logger.warning("ACME state collection failed", exc_info=True)
certs = []
cert_error = _friendly_acme_error(exc)
account = _parse_account_conf()
return {
"certs": certs,
"email": email,
"account": account,
"status": {"error": cert_error},
"timestamp": _now_iso(),
}
register_collector("acme", _collect_acme)