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:
+32
-8
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user