docs: update documentation and project structure

- Update AGENTS.md, README.md, and docs/* with revisions
- Refactor lib/acme.py and lib/state.py
- Add tests for acme module
- Remove install.sh and restart-services.sh (moved to scripts/)
- Normalize vendor files (acme.sh, htm.js)
This commit is contained in:
2026-07-02 14:41:02 +00:00
parent b4d13c4bd5
commit fb39af126a
15 changed files with 291 additions and 9019 deletions
+55 -4
View File
@@ -248,7 +248,7 @@ def list_certs() -> list[dict]:
A list of dicts, one per certificate, with keys matching
the cert-info schema (domain, ca, cert_path, etc.).
"""
raw = _run_acme(["--list"])
raw = _run_acme(["--list", "--listraw"])
certs: list[dict] = []
entries = _parse_list_output(raw)
@@ -472,7 +472,7 @@ def deploy(domain: str) -> None:
def _split_line(line: str, separator: str | None) -> list[str]:
"""Split a line by *separator*, falling back to whitespace for column output."""
if separator in line:
if separator is not None and separator in line:
return line.split(separator)
return line.split()
@@ -503,8 +503,8 @@ def _parse_list_output(raw: str) -> list[dict]:
headers = _split_line(header_line, "\t")
separator = "\t"
else:
headers = _split_line(header_line, None) # whitespace
separator = None
# Column-aligned: use position-based parsing via helper
return _parse_column_aligned(header_line, lines[1:])
if "Main_Domain" not in headers:
raise ValueError(
@@ -526,6 +526,57 @@ def _parse_list_output(raw: str) -> list[dict]:
return entries
def _find_header_positions(header_line: str):
"""Find start positions of each header word in a column-aligned header."""
names: list[str] = []
starts: list[int] = []
i = 0
while i < len(header_line):
while i < len(header_line) and header_line[i] == " ":
i += 1
j = i
while j < len(header_line) and header_line[j] != " ":
j += 1
if j > i:
names.append(header_line[i:j])
starts.append(i)
i = j
return names, starts
def _parse_column_aligned(header_line: str, data_lines: list[str]) -> list[dict]:
"""Parse column-aligned output using header positions to locate fields.
Unlike simple whitespace splitting, this preserves empty fields by using
character positions rather than token counts. Empty columns (e.g. missing
Profile or SAN_Domains) are correctly handled.
"""
names, starts = _find_header_positions(header_line)
if "Main_Domain" not in names:
raise ValueError(
f"acme.sh --list output is not in expected format: {header_line!r}"
)
# Column ends: midpoint before next header starts (or end of line for last)
ends: list[int] = len(starts) * [len(header_line)]
for i in range(len(starts) - 1):
ends[i] = (starts[i] + starts[i + 1]) // 2
entries: list[dict] = []
for line in data_lines:
line = line.rstrip()
if not line.strip():
continue
entry: dict[str, str] = {}
for k, name in enumerate(names):
s, e = starts[k], ends[k]
cell = line[s:e] if len(line) > s else ""
entry[name.lower()] = cell.strip().strip('"')
entries.append(entry)
return entries
def _days_until(date_str: str) -> int | None:
"""Parse an ISO date string and return days until that date from now."""
if not date_str:
+2 -40
View File
@@ -818,48 +818,10 @@ def _collect_acme() -> dict[str, Any]:
"""
email = _get_acme_email()
certs: list[dict[str, Any]] = []
try:
from lib.acme import (
_days_until,
_has_auto_renew,
_parse_list_output,
_run_acme,
)
from lib.acme import list_certs
raw = _run_acme(["--list"])
entries = _parse_list_output(raw)
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
for entry in entries:
main = entry.get("main_domain", "")
if not main:
continue
san_domains = [
d.strip()
for d in entry.get("san_domains", "").split(",")
if d.strip() and d.strip().lower() != "no"
]
cert_dir = acme_home / main
days = _days_until(entry.get("renew", ""))
certs.append(
{
"domain": main,
"issuer": entry.get("ca", ""),
"expiry": entry.get("renew", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": str(cert_dir / "fullchain.cer"),
"key_path": str(cert_dir / f"{main}.key"),
"ca_path": str(cert_dir / "ca.cer"),
"issued_at": entry.get("created", ""),
"expires_at": entry.get("renew", ""),
"days_until_expiry": days,
"auto_renew": _has_auto_renew(main),
"san_domains": san_domains,
}
)
certs = list_certs()
except Exception:
logger.warning(
"ACME state collection failed, returning empty cert list",