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
+32
View File
@@ -277,3 +277,35 @@ class TestHasAutoRenew:
with patch.object(acme, "_ACME_HOME", acme_dir):
result = acme._has_auto_renew("nonexistent.com")
assert result is False
class TestSummarizeAcmeOutput:
def test_last_two_lines(self):
out = (
"[2026-09-04] line one\n[2026-09-04] retry failed\n[2026-09-04] giving up\n"
)
assert acme._summarize_acme_output(out) == "retry failed; giving up"
def test_strips_timestamps_and_log_pointer(self):
out = "[ts] work\nPlease check log file /x/acme.sh.log\n[ts] done\n"
assert acme._summarize_acme_output(out) == "work; done"
def test_empty_returns_placeholder(self):
assert acme._summarize_acme_output("") == "(no output)"
def test_preserves_permission_denied_outside_tail(self):
out = (
"[ts] starting\n"
"[ts] /data/acme/account.conf: Permission denied\n"
"[ts] step three\n"
"[ts] step four\n"
)
summary = acme._summarize_acme_output(out)
# The permission line is not among the final two, but the
# actionable-error matcher (daemon/collectors/acme.py) keys off it.
assert "account.conf: Permission denied" in summary
assert summary.count("; ") == 2 # capped at three lines
def test_permission_denied_in_tail_not_duplicated(self):
out = "[ts] ok\n[ts] account.conf: Permission denied\n"
assert acme._summarize_acme_output(out) == "ok; account.conf: Permission denied"
+20
View File
@@ -349,6 +349,26 @@ class TestGenerateServerConf:
out = nginx.generate_server_conf(cfg)
assert "location /static/" not in out
def test_management_static_location_on_subpath(self, temp_data_dir):
# The SPA references /static/... at the domain root regardless of the
# management backend path, so the block is emitted for any
# is_management path, not only '/'.
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/app": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "location /static/ {" in out
static_root = str(nginx.PROJECT_DIR / "webui" / "static")
assert f"alias {static_root}/;" in out
def test_websocket_path(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
+78 -2
View File
@@ -1,6 +1,7 @@
"""Tests for lib/state.py — state store and collect functions."""
import json
import os
from unittest.mock import patch
import daemon.collectors.acme
@@ -267,6 +268,9 @@ class TestAcmeCollectNonFatal:
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
),
patch("daemon.handlers.acme.normalize_acme_home"),
patch(
"lib.acme.list_certs",
@@ -290,6 +294,9 @@ class TestAcmeCollectNonFatal:
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
),
patch("daemon.handlers.acme.normalize_acme_home"),
patch("lib.acme.list_certs", return_value=[]),
patch.object(
@@ -316,6 +323,9 @@ class TestAcmeCollectNonFatal:
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
),
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
patch("lib.acme.list_certs", side_effect=_list),
patch.object(
@@ -324,12 +334,78 @@ class TestAcmeCollectNonFatal:
):
result = _collect_acme()
# The poll must normalize ACME_HOME perms before listing, so a
# mid-lifetime ownership flip self-heals without a restart.
# The poll must normalize ACME_HOME perms before listing when the
# probe detects a lost group-read bit, so a mid-lifetime ownership
# flip self-heals without a restart.
assert order == ["normalize", "list"]
assert result["certs"] == [{"domain": "example.com"}]
assert result["status"] == {"error": None}
def test_no_normalize_when_probe_clean(self):
from daemon.collectors.acme import _collect_acme
order: list[str] = []
def _norm():
order.append("normalize")
def _list():
order.append("list")
return []
with (
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=False
),
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
patch("lib.acme.list_certs", side_effect=_list),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
# Steady state: the probe sees group-read bits intact, so the poll
# must not pay for a sudo normalize.
assert order == ["list"]
assert result["certs"] == []
assert result["status"] == {"error": None}
class TestAcmeHomeProbe:
"""_acme_home_needs_normalize probes the group-read bit without sudo."""
def test_flags_file_without_group_read(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path))
(tmp_path / "account.conf").write_text("x")
os.chmod(tmp_path / "account.conf", 0o600)
assert _acme_home_needs_normalize() is True
def test_clean_when_group_read_set(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path))
(tmp_path / "account.conf").write_text("x")
os.chmod(tmp_path / "account.conf", 0o640)
assert _acme_home_needs_normalize() is False
def test_clean_on_empty_home(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path))
assert _acme_home_needs_normalize() is False
def test_clean_on_missing_home(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path / "does-not-exist"))
assert _acme_home_needs_normalize() is False
def test_permission_error_is_actionable(self):
from daemon.collectors.acme import _collect_acme