feat: add ACME account management with validation pipeline

- Register, view, and deactivate ACME accounts via API and UI
- 16-check validation framework for certificate issuance readiness
- DNS resolution, port, nginx, and firewall pre-flight checks
- External IP detection with NAT support and fallback providers
- Account card and settings modal in certificates page
- Guard certificate issuance behind account registration
- Update modal CSS to overlay-based approach
- 1000+ lines of tests for validation and account handlers
This commit is contained in:
2026-06-23 14:24:19 +00:00
parent 3a325504ec
commit 5025dfaf30
19 changed files with 2073 additions and 141 deletions
+65
View File
@@ -586,6 +586,68 @@ def _parse_acme_list_output(raw: str) -> list[dict]:
return entries
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"""Parse acme.sh .account.conf and return account status dict.
Args:
acme_home: Optional override for ACME home directory. Falls back
to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``.
Returns:
Dict with ``registered``, ``email``, ``ca``, and
``key_length`` keys. If the file is missing or keys are absent,
``registered`` is ``False`` with empty / ``None`` values.
"""
if acme_home is None:
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
account_path = acme_home / ".account.conf"
default = {
"registered": False,
"email": "",
"ca": "",
"key_length": None,
}
if not account_path.is_file():
return default
try:
text = account_path.read_text()
except OSError:
return default
email = ""
ca_raw = ""
key_length = None
for line in text.splitlines():
if line.startswith("ACME_LEEMAIL="):
email = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_MCA="):
ca_raw = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_CERTKEYSIZE="):
raw_val = line.split("=", 1)[1].strip().strip("'\"")
key_length = int(raw_val) if raw_val.isdigit() else None
if not email or not ca_raw:
return default
ca_map = {
"letsencrypt": "Let's Encrypt",
"zerossl": "ZeroSSL",
}
ca = ca_map.get(ca_raw, ca_raw)
return {
"registered": True,
"email": email,
"ca": ca,
"key_length": key_length,
}
def _has_auto_renew(domain: str) -> bool:
"""Check whether *domain* has an auto-renew configuration file.
@@ -653,9 +715,12 @@ def _collect_acme() -> dict[str, Any]:
except Exception:
pass
account = _parse_account_conf()
return {
"certs": certs,
"email": email,
"account": account,
"timestamp": _now_iso(),
}