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:
+129
-24
@@ -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)
|
||||
|
||||
@@ -121,6 +121,7 @@ POST_ACME_VALIDATE: Endpoint = _ep("POST", "/acme/validate")
|
||||
POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue")
|
||||
GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
|
||||
POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew")
|
||||
GET_ACME_RENEW_STATUS: Endpoint = _ep("GET", "/acme/renew/status")
|
||||
DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
|
||||
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
|
||||
GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email")
|
||||
|
||||
+38
-3
@@ -1333,11 +1333,46 @@ Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to
|
||||
POST /api/certs/<domain>/renew
|
||||
```
|
||||
|
||||
Force-renew an existing certificate.
|
||||
Start an async certificate renewal for an existing certificate. The renewal
|
||||
runs in the background and is polled via
|
||||
`GET /api/certs/renew/<request_id>`.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
**Request Body:** none (domain is taken from the path).
|
||||
|
||||
Returns HTTP `404` if the certificate is not found. Returns HTTP `500` if renewal fails.
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `request_id` | `string` | Unique identifier for polling renewal status |
|
||||
| `domain` | `string` | Domain being renewed |
|
||||
| `status` | `string` | Only when a renewal for this domain is already in progress (`"existing"` — the existing `request_id` is returned) |
|
||||
|
||||
The renewal is a **no-op** when the certificate's renewal window (default:
|
||||
30 days before expiry) has not been reached — the request then completes with
|
||||
`status: "skipped"`.
|
||||
|
||||
Returns HTTP `400` if the domain is missing. Returns HTTP `500` when the
|
||||
renewal cannot be started (e.g. daemon unreachable).
|
||||
|
||||
---
|
||||
|
||||
#### Poll Certificate Renewal Status
|
||||
|
||||
```
|
||||
GET /api/certs/renew/<request_id>
|
||||
```
|
||||
|
||||
Poll the status of a certificate renewal started by
|
||||
`POST /api/certs/<domain>/renew`.
|
||||
|
||||
**Response (`data`):** Renewal status object containing `request_id`,
|
||||
`domain`, `status` (`"running"`, `"completed"`, `"skipped"`, or `"failed"`),
|
||||
a `steps` array (each with per-step status and error message), and
|
||||
timestamps.
|
||||
|
||||
Returns HTTP `404` if the request ID is not found. The frontend uses
|
||||
`poll()` to repeatedly fetch this endpoint until the renewal completes,
|
||||
is skipped, or fails.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1052,6 +1052,8 @@ ActionCell({
|
||||
| `removeLabel` | Delete button label (default: `'Remove'`) |
|
||||
| `removeBody` | Optional JSON body to send with DELETE |
|
||||
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
||||
| `busy` | When `true` the action button is disabled and shows `busyLabel` (use for in-flight operations). |
|
||||
| `busyLabel` | Label shown while `busy` (default: `editLabel` + `'…'`) |
|
||||
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
|
||||
|
||||
#### `certStatusBadge(props)`
|
||||
|
||||
@@ -530,6 +530,64 @@ class TestCertsIssue:
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestCertsRenew:
|
||||
@_ce("post")
|
||||
def test_start_renew(self, mock_post, client):
|
||||
mock_post.return_value = {"request_id": "abc123", "domain": "example.com"}
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["request_id"] == "abc123"
|
||||
args = mock_post.call_args.args
|
||||
assert args[0] == ("POST", "/acme/renew")
|
||||
assert args[1] == {"domain": "example.com"}
|
||||
|
||||
@_ce("post")
|
||||
def test_start_renew_rejected(self, mock_post, client):
|
||||
from daemon.client import BadRequest
|
||||
|
||||
mock_post.side_effect = BadRequest("'domain' is required")
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
@_ce("post")
|
||||
def test_start_renew_runtime_error(self, mock_post, client):
|
||||
mock_post.side_effect = RuntimeError("daemon unreachable")
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestCertsRenewStatus:
|
||||
@_ce("get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"request_id": "abc123",
|
||||
"domain": "example.com",
|
||||
"status": "completed",
|
||||
"steps": [],
|
||||
}
|
||||
resp = client.get("/api/certs/renew/abc123")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["status"] == "completed"
|
||||
args = mock_get.call_args.args
|
||||
assert args[0] == ("GET", "/acme/renew/status")
|
||||
assert args[1] == {"id": "abc123"}
|
||||
|
||||
@_ce("get")
|
||||
def test_not_found(self, mock_get, client):
|
||||
from daemon.client import NotFound
|
||||
|
||||
mock_get.side_effect = NotFound("renewal request not found")
|
||||
resp = client.get("/api/certs/renew/unknown")
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestCertsEmail:
|
||||
def test_missing_email(self, client):
|
||||
resp = client.post("/api/certs/email", json={})
|
||||
|
||||
+139
-1
@@ -7,7 +7,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import daemon.handlers.acme as acme_mod
|
||||
from daemon.handlers.acme import (
|
||||
IssueRequest,
|
||||
_check_account_registered,
|
||||
_check_acme_account,
|
||||
_check_acme_home_writable,
|
||||
@@ -25,10 +27,25 @@ from daemon.handlers.acme import (
|
||||
deactivate_account,
|
||||
generate_self_signed,
|
||||
get_account,
|
||||
get_renew_status,
|
||||
issue_cert,
|
||||
register_account,
|
||||
renew_cert,
|
||||
)
|
||||
from daemon.server import ConflictError
|
||||
from daemon.server import ConflictError, NotFoundError
|
||||
|
||||
|
||||
def _await_renew(body):
|
||||
"""Start a renewal via renew_cert and await its background task."""
|
||||
|
||||
async def _run():
|
||||
result = renew_cert(None, body)
|
||||
task = acme_mod._ISSUANCE_TASKS.get(result.get("request_id"))
|
||||
if task is not None:
|
||||
await task
|
||||
return result
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
class TestGenerateSelfSigned:
|
||||
@@ -1165,3 +1182,124 @@ class TestIssueCertExistingCerts:
|
||||
assert result["domain"] == "example.com"
|
||||
assert "request_id" in result
|
||||
mock_run_issue.assert_called_once()
|
||||
|
||||
|
||||
class TestRenewCert:
|
||||
def test_requires_body(self):
|
||||
with pytest.raises(ValueError, match="Request body required"):
|
||||
renew_cert(None, None)
|
||||
|
||||
def test_requires_domain(self):
|
||||
with pytest.raises(ValueError, match="'domain' is required"):
|
||||
renew_cert(None, {"force": True})
|
||||
|
||||
def test_starts_background_task(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch(
|
||||
"daemon.handlers.acme._run_acme", return_value="Renewed 'example.com'"
|
||||
),
|
||||
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert result["domain"] == "example.com"
|
||||
assert "request_id" in result
|
||||
assert req.status == "completed"
|
||||
assert [s.status for s in req.steps] == ["done", "done", "done"]
|
||||
mock_refresh.assert_called_once_with(["acme"])
|
||||
|
||||
def test_skip_when_not_due(self):
|
||||
output = (
|
||||
"[Thu Aug 20 01:27:41 AM UTC 2026] Skipping. "
|
||||
"Next renewal time is: 1785068261 (2026-07-26T12:17:41Z)"
|
||||
)
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value=output),
|
||||
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert req.status == "skipped"
|
||||
assert req.steps[0].status == "done"
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
def test_failure_marks_failed(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch(
|
||||
"daemon.handlers.acme._run_acme",
|
||||
side_effect=RuntimeError("acme.sh failed with exit code 1: boom"),
|
||||
),
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert req.status == "failed"
|
||||
assert req.steps[0].status == "error"
|
||||
assert "boom" in req.steps[0].message
|
||||
|
||||
def test_force_appends_flag(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value="ok") as mock_acme,
|
||||
patch("daemon.handlers.acme.refresh_state"),
|
||||
):
|
||||
_await_renew({"domain": "example.com", "force": True})
|
||||
|
||||
renew_args = mock_acme.call_args_list[0].args[0]
|
||||
assert "--force" in renew_args
|
||||
|
||||
def test_dedup_running_returns_existing(self):
|
||||
existing = IssueRequest(request_id="existing123", domain="example.com")
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme") as mock_acme,
|
||||
):
|
||||
acme_mod._ISSUANCES["existing123"] = existing
|
||||
result = renew_cert(None, {"domain": "example.com"})
|
||||
|
||||
assert result == {
|
||||
"request_id": "existing123",
|
||||
"domain": "example.com",
|
||||
"status": "existing",
|
||||
}
|
||||
mock_acme.assert_not_called()
|
||||
|
||||
|
||||
class TestGetRenewStatus:
|
||||
def test_missing_id(self):
|
||||
with pytest.raises(ValueError, match="'id' is required"):
|
||||
get_renew_status(None, None)
|
||||
with pytest.raises(ValueError, match="'id' is required"):
|
||||
get_renew_status(None, {})
|
||||
|
||||
def test_unknown_id(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
pytest.raises(NotFoundError, match="not found"),
|
||||
):
|
||||
get_renew_status(None, {"id": "unknown"})
|
||||
|
||||
def test_returns_request_dict(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value="Renewed"),
|
||||
patch("daemon.handlers.acme.refresh_state"),
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
status = get_renew_status(None, {"id": result["request_id"]})
|
||||
|
||||
assert status["request_id"] == result["request_id"]
|
||||
assert status["domain"] == "example.com"
|
||||
assert status["status"] == "completed"
|
||||
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
|
||||
|
||||
+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