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.
+5 -2
View File
@@ -314,6 +314,8 @@ export function ServiceStatus(props = {}) {
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
* @param {boolean} [props.busy] - When true the action button is disabled (in-flight operation)
* @param {string} [props.busyLabel] - Label shown while busy (default: editLabel + '…')
* @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling
*/
export function ActionCell(props = {}) {
@@ -321,8 +323,9 @@ export function ActionCell(props = {}) {
h('button', {
class: props.editCls || 'btn btn-sm btn-outline',
style: 'margin-right:4px;',
'on:click': props.editClick,
}, props.editLabel),
disabled: !!props.busy,
'on:click': props.busy ? undefined : props.editClick,
}, props.busy ? (props.busyLabel || (props.editLabel + '…')) : props.editLabel),
ConfirmDelete({
url: props.removeUrl,
message: props.removeMessage,
+58 -6
View File
@@ -1,6 +1,61 @@
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js';
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction, requestUpdate } from '/static/hoover/index.js';
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js';
// Domains with an in-flight renewal (button disabled while pending).
const _renewInFlight = new Set();
function renewCert(domain) {
if (_renewInFlight.has(domain)) return;
_renewInFlight.add(domain);
requestUpdate();
const done = () => {
if (_renewInFlight.delete(domain)) requestUpdate();
};
apiFetch('/api/certs/' + enc(domain) + '/renew', { method: 'POST' }).then(resp => {
if (!resp.ok) {
done();
toast(resp.error || 'Renewal failed', 'error');
return;
}
const rid = resp.data?.request_id;
if (!rid) { done(); toast('Renewal not started', 'error'); return; }
if (resp.data?.status === 'existing') {
toast('Renewal already in progress for ' + domain, 'warning');
} else {
toast('Renewal started for ' + domain, 'success');
}
poll({
url: '/api/certs/renew/' + enc(rid),
successKey: (d) => d.status === 'completed',
onErrorKey: (d) => d.status === 'failed' || d.status === 'skipped',
timeout: 180000,
onComplete: () => {
done();
toast('Certificate renewed for ' + domain, 'success');
},
onError: (d) => {
done();
if (d && d.status === 'skipped') {
toast('Certificate still valid — renewal skipped for ' + domain, 'info');
return;
}
let msg = (d && d.error) || 'unknown';
if (d == null) msg = 'timed out waiting for renewal';
else if (d.steps) {
const failed = d.steps.find(s => s.status === 'error');
if (failed && failed.message) msg = failed.message;
}
toast('Renewal failed for ' + domain + ': ' + msg, 'error');
},
});
}).catch((err) => {
done();
toast(err?.message || 'Renewal failed', 'error');
});
}
function _accountCard(account) {
if (!account || !account.registered) {
return html`<div class="card">
@@ -291,11 +346,8 @@ export default definePage({
<td>${badge}</td>
<${ActionCell}
editLabel="Renew"
editClick=${async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error');
}}
editClick=${() => renewCert(c.domain)}
busy=${_renewInFlight.has(c.domain)}
removeUrl=${'/api/certs/' + enc(c.domain)}
removeMessage=${'Remove certificate for ' + c.domain + '?'}
removeSuccess="Certificate removed"