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:
+55
-4
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user