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
+129 -24
View File
@@ -26,6 +26,7 @@ from daemon.iface import (
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
GET_ACME_PATHS,
GET_ACME_RENEW_STATUS,
POST_ACME_ACCOUNT_REGISTER,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
@@ -174,6 +175,21 @@ def _clean_expired_issuances() -> None:
del _ISSUANCES[rid]
def _fail_op(req: IssueRequest, exc: Exception) -> None:
"""Record the error on the first running step and mark the request failed."""
for step in req.steps:
if step.status == "running":
step.status = "error"
step.message = str(exc)
break
else:
req.steps.append(
IssueStep(name="error", label="Error", status="error", message=str(exc))
)
req.status = "failed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
# ---------------------------------------------------------------------------
# Validation helpers
@@ -821,13 +837,17 @@ async def _run_issue(req: IssueRequest) -> None:
if account_email:
args.extend(["-m", account_email])
args.append("--force")
output = _run_acme(args)
# acme.sh is a blocking subprocess — run it off the event loop so
# polling, WS broadcasts, and other requests keep responding.
output = await asyncio.to_thread(_run_acme, args)
req.steps[0].status = "done"
req.steps[0].message = output.strip()[:200]
# Step 2: deploy
req.steps[1].status = "running"
_run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK])
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
@@ -844,17 +864,7 @@ async def _run_issue(req: IssueRequest) -> None:
)
except Exception as exc:
# Mark current running step as error, overall as failed
for step in req.steps:
if step.status == "running":
step.status = "error"
step.message = str(exc)
break
else:
req.steps.append(
IssueStep(name="error", label="Error", status="error", message=str(exc))
)
req.status = "failed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
_fail_op(req, exc)
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
finally:
_ISSUANCE_TASKS.pop(req.request_id, None)
@@ -862,28 +872,123 @@ async def _run_issue(req: IssueRequest) -> None:
@registry.register(POST_ACME_RENEW)
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/renew — renew a certificate for the given domain.
"""POST /acme/renew — start a certificate renewal request (async).
Deduplicates in-progress requests per domain. Spawns a background task
for the actual renewal. When ``force`` is not set, acme.sh skips the
renewal if the certificate's renewal window has not been reached yet
(the request then completes with status "skipped").
Args:
force: Force renewal regardless of expiry.
Returns:
A dictionary containing the renewal request id (and "existing"
status when a renewal for the domain is already running).
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
domain = (body.get("domain") or "").strip()
if not domain:
raise ValueError("'domain' is required")
force = body.get("force", False)
args: list[str] = ["--renew", "-d", domain]
if force:
args.append("--force")
output = _run_acme(args)
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
logger.info("Certificate for %s renewed", domain)
refresh_state(["acme"])
return {"domain": domain, "output": output.strip()}
force = bool(body.get("force", False))
_clean_expired_issuances()
# Dedup: if domain already has an active request, return it
existing = _find_issuance(domain)
if existing:
return {
"request_id": existing.request_id,
"domain": domain,
"status": "existing",
}
request_id = uuid4().hex[:12]
steps = [
IssueStep(name="renew", label="Renewing certificate"),
IssueStep(name="deploy", label="Registering deploy hook"),
IssueStep(name="refresh", label="Refreshing certificate state"),
]
req = IssueRequest(request_id=request_id, domain=domain, steps=steps)
_ISSUANCES[request_id] = req
# Spawn background task
_task = asyncio.create_task(_run_renew(req, force))
_ISSUANCE_TASKS[request_id] = _task
return {"request_id": request_id, "domain": domain}
@registry.register(GET_ACME_RENEW_STATUS)
def get_renew_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /acme/renew/status — poll status of a renewal request.
Raises:
ValueError: When id is missing.
NotFoundError: When request_id is unknown.
"""
request_id = (body or {}).get("id", "").strip()
if not request_id:
raise ValueError("'id' is required")
req = _ISSUANCES.get(request_id)
if not req:
raise NotFoundError(f"Renewal request {request_id} not found")
return req.to_dict()
async def _run_renew(req: IssueRequest, force: bool) -> None:
"""Background task: renew the certificate, register the deploy hook, refresh state."""
try:
# Step 1: renew (acme.sh skips when the cert's renewal window has not
# been reached unless force is set)
req.steps[0].status = "running"
args: list[str] = ["--renew", "-d", req.domain]
if force:
args.append("--force")
output = await asyncio.to_thread(_run_acme, args)
if "Skipping." in output:
req.steps[0].status = "done"
req.steps[0].message = "Renewal not yet due — skipped"
req.status = "skipped"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
logger.info(
"Renewal for %s skipped (request %s)", req.domain, req.request_id
)
return
req.steps[0].status = "done"
req.steps[0].message = output.strip()[:200]
# Step 2: deploy
req.steps[1].status = "running"
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
# Step 3: refresh state
req.steps[2].status = "running"
refresh_state(["acme"])
req.steps[2].status = "done"
req.steps[2].message = "State refreshed"
req.status = "completed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
logger.info(
"Certificate for %s renewed (request %s)", req.domain, req.request_id
)
except Exception as exc:
_fail_op(req, exc)
logger.error("Renewal for %s failed: %s", req.domain, exc)
finally:
_ISSUANCE_TASKS.pop(req.request_id, None)
@registry.register(DELETE_ACME_REMOVE)