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:
+48
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user