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_ISSUE_STATUS,
|
||||||
GET_ACME_LIST,
|
GET_ACME_LIST,
|
||||||
GET_ACME_PATHS,
|
GET_ACME_PATHS,
|
||||||
|
GET_ACME_RENEW_STATUS,
|
||||||
POST_ACME_ACCOUNT_REGISTER,
|
POST_ACME_ACCOUNT_REGISTER,
|
||||||
POST_ACME_EMAIL,
|
POST_ACME_EMAIL,
|
||||||
POST_ACME_ISSUE,
|
POST_ACME_ISSUE,
|
||||||
@@ -174,6 +175,21 @@ def _clean_expired_issuances() -> None:
|
|||||||
del _ISSUANCES[rid]
|
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
|
# Validation helpers
|
||||||
|
|
||||||
@@ -821,13 +837,17 @@ async def _run_issue(req: IssueRequest) -> None:
|
|||||||
if account_email:
|
if account_email:
|
||||||
args.extend(["-m", account_email])
|
args.extend(["-m", account_email])
|
||||||
args.append("--force")
|
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].status = "done"
|
||||||
req.steps[0].message = output.strip()[:200]
|
req.steps[0].message = output.strip()[:200]
|
||||||
|
|
||||||
# Step 2: deploy
|
# Step 2: deploy
|
||||||
req.steps[1].status = "running"
|
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].status = "done"
|
||||||
req.steps[1].message = "Deploy hook registered"
|
req.steps[1].message = "Deploy hook registered"
|
||||||
|
|
||||||
@@ -844,17 +864,7 @@ async def _run_issue(req: IssueRequest) -> None:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Mark current running step as error, overall as failed
|
# Mark current running step as error, overall as failed
|
||||||
for step in req.steps:
|
_fail_op(req, exc)
|
||||||
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
|
|
||||||
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
|
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
|
||||||
finally:
|
finally:
|
||||||
_ISSUANCE_TASKS.pop(req.request_id, None)
|
_ISSUANCE_TASKS.pop(req.request_id, None)
|
||||||
@@ -862,28 +872,123 @@ async def _run_issue(req: IssueRequest) -> None:
|
|||||||
|
|
||||||
@registry.register(POST_ACME_RENEW)
|
@registry.register(POST_ACME_RENEW)
|
||||||
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
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:
|
Args:
|
||||||
force: Force renewal regardless of expiry.
|
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:
|
Raises:
|
||||||
ValueError: When domain is missing.
|
ValueError: When domain is missing.
|
||||||
"""
|
"""
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
domain = body.get("domain", "").strip()
|
domain = (body.get("domain") or "").strip()
|
||||||
if not domain:
|
if not domain:
|
||||||
raise ValueError("'domain' is required")
|
raise ValueError("'domain' is required")
|
||||||
force = body.get("force", False)
|
force = bool(body.get("force", False))
|
||||||
args: list[str] = ["--renew", "-d", domain]
|
|
||||||
if force:
|
_clean_expired_issuances()
|
||||||
args.append("--force")
|
|
||||||
output = _run_acme(args)
|
# Dedup: if domain already has an active request, return it
|
||||||
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
existing = _find_issuance(domain)
|
||||||
logger.info("Certificate for %s renewed", domain)
|
if existing:
|
||||||
refresh_state(["acme"])
|
return {
|
||||||
return {"domain": domain, "output": output.strip()}
|
"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)
|
@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")
|
POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue")
|
||||||
GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
|
GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
|
||||||
POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew")
|
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")
|
DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
|
||||||
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
|
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
|
||||||
GET_ACME_EMAIL: Endpoint = _ep("GET", "/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
|
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'`) |
|
| `removeLabel` | Delete button label (default: `'Remove'`) |
|
||||||
| `removeBody` | Optional JSON body to send with DELETE |
|
| `removeBody` | Optional JSON body to send with DELETE |
|
||||||
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
| `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. |
|
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
|
||||||
|
|
||||||
#### `certStatusBadge(props)`
|
#### `certStatusBadge(props)`
|
||||||
|
|||||||
@@ -530,6 +530,64 @@ class TestCertsIssue:
|
|||||||
assert resp.status_code == 400
|
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:
|
class TestCertsEmail:
|
||||||
def test_missing_email(self, client):
|
def test_missing_email(self, client):
|
||||||
resp = client.post("/api/certs/email", json={})
|
resp = client.post("/api/certs/email", json={})
|
||||||
|
|||||||
+139
-1
@@ -7,7 +7,9 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import daemon.handlers.acme as acme_mod
|
||||||
from daemon.handlers.acme import (
|
from daemon.handlers.acme import (
|
||||||
|
IssueRequest,
|
||||||
_check_account_registered,
|
_check_account_registered,
|
||||||
_check_acme_account,
|
_check_acme_account,
|
||||||
_check_acme_home_writable,
|
_check_acme_home_writable,
|
||||||
@@ -25,10 +27,25 @@ from daemon.handlers.acme import (
|
|||||||
deactivate_account,
|
deactivate_account,
|
||||||
generate_self_signed,
|
generate_self_signed,
|
||||||
get_account,
|
get_account,
|
||||||
|
get_renew_status,
|
||||||
issue_cert,
|
issue_cert,
|
||||||
register_account,
|
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:
|
class TestGenerateSelfSigned:
|
||||||
@@ -1165,3 +1182,124 @@ class TestIssueCertExistingCerts:
|
|||||||
assert result["domain"] == "example.com"
|
assert result["domain"] == "example.com"
|
||||||
assert "request_id" in result
|
assert "request_id" in result
|
||||||
mock_run_issue.assert_called_once()
|
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_INFO,
|
||||||
GET_ACME_ISSUE_STATUS,
|
GET_ACME_ISSUE_STATUS,
|
||||||
GET_ACME_LIST,
|
GET_ACME_LIST,
|
||||||
|
GET_ACME_RENEW_STATUS,
|
||||||
POST_ACME_ACCOUNT_REGISTER,
|
POST_ACME_ACCOUNT_REGISTER,
|
||||||
POST_ACME_EMAIL,
|
POST_ACME_EMAIL,
|
||||||
POST_ACME_ISSUE,
|
POST_ACME_ISSUE,
|
||||||
@@ -144,19 +145,21 @@ def issue_status(request_id: str):
|
|||||||
|
|
||||||
@bp.route("/<domain>/renew", methods=["POST"])
|
@bp.route("/<domain>/renew", methods=["POST"])
|
||||||
def renew_bp(domain: str):
|
def renew_bp(domain: str):
|
||||||
"""POST /api/certs/<domain>/renew — renew an existing certificate.
|
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
|
||||||
|
|
||||||
Args:
|
|
||||||
domain: Domain name whose certificate should be renewed.
|
|
||||||
|
|
||||||
Returns:
|
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:
|
try:
|
||||||
logger.info("Certificate renewal requested for '%s' via API", domain)
|
logger.info("Certificate renewal requested for '%s' via API", domain)
|
||||||
post(POST_ACME_RENEW, {"domain": domain})
|
result = post(POST_ACME_RENEW, {"domain": domain})
|
||||||
logger.info("Certificate renewed for '%s'", domain)
|
logger.info(
|
||||||
return _ok(None)
|
"Certificate renewal started for '%s' (id=%s)",
|
||||||
|
domain,
|
||||||
|
result.get("request_id"),
|
||||||
|
)
|
||||||
|
return _ok(result)
|
||||||
except BadRequest as exc:
|
except BadRequest as exc:
|
||||||
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
|
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
|
||||||
return _error(str(exc), 400)
|
return _error(str(exc), 400)
|
||||||
@@ -165,6 +168,27 @@ def renew_bp(domain: str):
|
|||||||
return _error(str(exc), 500)
|
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"])
|
@bp.route("/<domain>", methods=["DELETE"])
|
||||||
def remove_bp(domain: str):
|
def remove_bp(domain: str):
|
||||||
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
|
"""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 {string} [props.removeLabel] - Delete button label (default: 'Remove')
|
||||||
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
|
* @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 {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
|
* @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling
|
||||||
*/
|
*/
|
||||||
export function ActionCell(props = {}) {
|
export function ActionCell(props = {}) {
|
||||||
@@ -321,8 +323,9 @@ export function ActionCell(props = {}) {
|
|||||||
h('button', {
|
h('button', {
|
||||||
class: props.editCls || 'btn btn-sm btn-outline',
|
class: props.editCls || 'btn btn-sm btn-outline',
|
||||||
style: 'margin-right:4px;',
|
style: 'margin-right:4px;',
|
||||||
'on:click': props.editClick,
|
disabled: !!props.busy,
|
||||||
}, props.editLabel),
|
'on:click': props.busy ? undefined : props.editClick,
|
||||||
|
}, props.busy ? (props.busyLabel || (props.editLabel + '…')) : props.editLabel),
|
||||||
ConfirmDelete({
|
ConfirmDelete({
|
||||||
url: props.removeUrl,
|
url: props.removeUrl,
|
||||||
message: props.removeMessage,
|
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';
|
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) {
|
function _accountCard(account) {
|
||||||
if (!account || !account.registered) {
|
if (!account || !account.registered) {
|
||||||
return html`<div class="card">
|
return html`<div class="card">
|
||||||
@@ -291,11 +346,8 @@ export default definePage({
|
|||||||
<td>${badge}</td>
|
<td>${badge}</td>
|
||||||
<${ActionCell}
|
<${ActionCell}
|
||||||
editLabel="Renew"
|
editLabel="Renew"
|
||||||
editClick=${async () => {
|
editClick=${() => renewCert(c.domain)}
|
||||||
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
|
busy=${_renewInFlight.has(c.domain)}
|
||||||
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
|
|
||||||
else toast(resp.error || 'Failed', 'error');
|
|
||||||
}}
|
|
||||||
removeUrl=${'/api/certs/' + enc(c.domain)}
|
removeUrl=${'/api/certs/' + enc(c.domain)}
|
||||||
removeMessage=${'Remove certificate for ' + c.domain + '?'}
|
removeMessage=${'Remove certificate for ' + c.domain + '?'}
|
||||||
removeSuccess="Certificate removed"
|
removeSuccess="Certificate removed"
|
||||||
|
|||||||
Reference in New Issue
Block a user