Refactor ACME module and add cert issuance conflict handling

- Move acme.sh utilities (_run_acme, _find_acme, etc.) from lib/state to lib/acme
- Rewrite _parse_list_output to support pipe, tab, and column-separated formats
- Add ConflictError (409) to block issuing when cert already exists
- Move _find_issuance helper to detect in-progress issuance per domain
- Update issue_cert to check existing certs and return issuance status
- Fix start_polling to accept event loop explicitly
- Add sudoers entry for chown on vacuum-wall.conf
- Extend systemd ReadWritePaths for /run/nginx.pid and /var/log/nginx
- Update frontend to handle 'existing' issuance status
This commit is contained in:
2026-06-27 00:38:49 +00:00
parent feaf253403
commit 398831b6e2
11 changed files with 268 additions and 176 deletions
+48 -19
View File
@@ -257,7 +257,7 @@ def list_certs() -> list[dict]:
continue
san_domains = [
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
d.strip() for d in entry.get("san_domains", "").split(",") if d.strip()
]
cert_dir = acme_home / main
@@ -265,21 +265,21 @@ def list_certs() -> list[dict]:
key_path = str(cert_dir / f"{main}.key")
ca_path = str(cert_dir / "ca.cer")
days = _days_until(entry.get("certificate_expires", ""))
days = _days_until(entry.get("renew", ""))
auto = _has_auto_renew(main)
certs.append(
{
"domain": main,
"issuer": entry.get("CA", ""),
"expiry": entry.get("certificate_expires", ""),
"issuer": entry.get("ca", ""),
"expiry": entry.get("renew", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": cert_path,
"key_path": key_path,
"ca_path": ca_path,
"issued_at": entry.get("certificate_date", ""),
"expires_at": entry.get("certificate_expires", ""),
"issued_at": entry.get("created", ""),
"expires_at": entry.get("renew", ""),
"days_until_expiry": days,
"auto_renew": auto,
"san_domains": san_domains,
@@ -437,28 +437,57 @@ 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:
return line.split(separator)
return line.split()
def _parse_list_output(raw: str) -> list[dict]:
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
"""Parse output from ``acme.sh --list`` into a list of dicts.
Each line in the output contains ``Key:Value`` tokens separated by
whitespace, e.g.::
Handles three formats depending on system capabilities:
- Raw pipe-separated output (``|``)
- Tab-separated output (when ``column`` is unavailable)
- Column-aligned output (when ``column`` is available)
Main_Domain:example.com SAN_Domain:www.example.com CA:Let's
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No
Keys are converted to lowercase in the returned dicts.
All formats share the same header: Main_Domain, KeyLength, SAN_Domains,
Profile, CA, Created, Renew.
"""
lines = raw.strip().splitlines()
if len(lines) < 2:
return []
header_line = lines[0]
# Detect separator from header: pipe, tab, or whitespace
if "|" in header_line:
headers = _split_line(header_line, "|")
separator = "|"
elif "\t" in header_line:
headers = _split_line(header_line, "\t")
separator = "\t"
else:
headers = _split_line(header_line, None) # whitespace
separator = None
if "Main_Domain" not in headers:
raise ValueError(
f"acme.sh --list output is not in expected format: {header_line!r}"
)
entries: list[dict] = []
for line in raw.strip().splitlines():
for line in lines[1:]:
line = line.strip()
if not line:
continue
fields = _split_line(line, separator)
entry: dict[str, str] = {}
for token in line.split():
if ":" not in token:
continue
key, _, value = token.partition(":")
entry[key.lower()] = value
for i, h in enumerate(headers):
if i < len(fields):
entry[h.lower()] = fields[i].strip().strip('"')
if entry:
entries.append(entry)
return entries