certs: async renewal via background task + status polling

- POST /acme/renew returns a request_id and spawns a background task
  (renew/deploy/refresh steps); dedups per-domain like issue
- completes as "skipped" when acme.sh reports the renewal window
  has not been reached (no --force)
- new GET /acme/renew/status endpoint (iface + handler + blueprint)
- run acme.sh subprocesses off the event loop (asyncio.to_thread)
  in both issuance and renewal
- ActionCell: busy/busyLabel props; certs page disables the Renew
  button and polls renewal status with toasts for success/skip/fail
This commit is contained in:
2026-08-20 13:10:16 +00:00
parent 332d14e37d
commit 94705490b9
9 changed files with 462 additions and 44 deletions
+32 -8
View File
@@ -15,6 +15,7 @@ from daemon.iface import (
GET_ACME_INFO,
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
GET_ACME_RENEW_STATUS,
POST_ACME_ACCOUNT_REGISTER,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
@@ -144,19 +145,21 @@ def issue_status(request_id: str):
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain: str):
"""POST /api/certs/<domain>/renew — renew an existing certificate.
Args:
domain: Domain name whose certificate should be renewed.
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
Returns:
Response confirming renewal or an error message.
Response containing a renewal request ID (poll it at
``/api/certs/renew/<request_id>``) or an error message.
"""
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
post(POST_ACME_RENEW, {"domain": domain})
logger.info("Certificate renewed for '%s'", domain)
return _ok(None)
result = post(POST_ACME_RENEW, {"domain": domain})
logger.info(
"Certificate renewal started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
@@ -165,6 +168,27 @@ def renew_bp(domain: str):
return _error(str(exc), 500)
@bp.route("/renew/<request_id>", methods=["GET"])
def renew_status(request_id: str):
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
Args:
request_id: Renewal request identifier returned by renew_bp.
Returns:
Response containing renewal status or an error message.
"""
try:
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Renewal request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
@bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain: str):
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.