feat: add ACME account management with validation pipeline

- Register, view, and deactivate ACME accounts via API and UI
- 16-check validation framework for certificate issuance readiness
- DNS resolution, port, nginx, and firewall pre-flight checks
- External IP detection with NAT support and fallback providers
- Account card and settings modal in certificates page
- Guard certificate issuance behind account registration
- Update modal CSS to overlay-based approach
- 1000+ lines of tests for validation and account handlers
This commit is contained in:
2026-06-23 14:24:19 +00:00
parent 3a325504ec
commit 5025dfaf30
19 changed files with 2073 additions and 141 deletions
+61 -4
View File
@@ -9,10 +9,13 @@ from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, post
from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE,
DELETE_ACME_REMOVE,
GET_ACME_ACCOUNT,
GET_ACME_INFO,
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
POST_ACME_ACCOUNT_REGISTER,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
POST_ACME_RENEW,
@@ -68,7 +71,7 @@ def validate():
Response containing validation results or an error message.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
try:
@@ -92,10 +95,10 @@ def issue_start():
Response containing an issuance request ID or an error message.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
email = body.get("email", "").strip() or None
email = (body.get("email") or "").strip() or None
webroot = body.get("webroot")
try:
logger.info("Certificate issuance requested for '%s' via API", domain)
@@ -192,7 +195,7 @@ def set_email_bp():
Response confirming the email was set or an error message.
"""
body = request.get_json(silent=True) or {}
email = body.get("email", "").strip()
email = (body.get("email") or "").strip()
if not email:
return _error("'email' is required", 400)
try:
@@ -205,3 +208,57 @@ def set_email_bp():
except RuntimeError as exc:
logger.error("Failed to set ACME email: %s", exc)
return _error(str(exc), 500)
@bp.route("/account", methods=["GET"])
def account():
"""GET /api/certs/account — return ACME account information.
Returns:
Response containing account status or an error message.
"""
try:
result = get(GET_ACME_ACCOUNT)
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to get ACME account: %s", exc)
return _error(str(exc), 500)
@bp.route("/account/register", methods=["POST"])
def register_account():
"""POST /api/certs/account/register — register a new ACME account.
Expects JSON body with ``{``email``, ``server``?}``.
Returns:
Response confirming registration or an error message.
"""
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip()
if not email:
return _error("'email' is required", 400)
server = (body.get("server") or "").strip()
try:
result = post(POST_ACME_ACCOUNT_REGISTER, {"email": email, "server": server})
return _ok(result)
except BadRequest as exc:
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to register ACME account: %s", exc)
return _error(str(exc), 500)
@bp.route("/account", methods=["DELETE"])
def deactivate_account():
"""DELETE /api/certs/account — deactivate the ACME account.
Returns:
Response confirming deactivation or an error message.
"""
try:
result = delete(DELETE_ACME_ACCOUNT_DEACTIVATE)
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to deactivate ACME account: %s", exc)
return _error(str(exc), 500)
+14 -3
View File
@@ -100,9 +100,20 @@ modelRegister('nginx', {
modelRegister('acme', {
subsystem: 'acme',
fetch: async () => {
const r = await apiFetch('/api/certs/list');
if (!r.ok) throw new Error(r.error);
return { certs: r.data || [] };
const [listR, acctR] = await Promise.allSettled([
apiFetch('/api/certs/list'),
apiFetch('/api/certs/account'),
]);
const result = {};
if (listR.status === 'fulfilled' && listR.value.ok) {
result.certs = listR.value.data || [];
} else if (listR.status === 'rejected' || !listR.value.ok) {
throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed'));
}
if (acctR.status === 'fulfilled' && acctR.value.ok) {
result.account = acctR.value.data || { registered: false, email: '', ca: '' };
}
return result;
},
});
+3 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vacuum Wall</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/style.css?v=8">
</head>
<body>
<div id="app">
@@ -13,7 +13,8 @@
<div class="main" id="main"></div>
</div>
</div>
<div id="modal-root"></div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js?v=7"></script>
<script type="module" src="/static/app.js?v=8"></script>
</body>
</html>
+251 -18
View File
@@ -1,30 +1,74 @@
import { h, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
import { h, PageHeader, Empty, Table, Card, Badge, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
const _issueState = { domain: '', modalIdx: -1, account: null, validating: false };
function issueCertModal(state) {
openModal((inner, idx) => {
formModal(inner, 'Issue Certificate',
function _accountCard(account) {
if (!account || !account.registered) {
return h('div', { class: 'card' },
h('div', { class: 'card-header' },
[
h('span', null, 'ACME Account'),
h('button', {
class: 'btn btn-sm btn-primary',
style: 'margin-left:auto;',
'on:click': () => registerAccountModal(),
}, 'Register Account'),
]
),
h('div', { class: 'card-body' },
h('div', { class: 'text-muted text-sm' }, 'Not registered'),
),
);
}
return h('div', { class: 'card' },
h('div', { class: 'card-header' },
[
{ label: 'Domain', id: 'ic-domain', placeholder: 'example.com' },
{ label: 'Email (optional)', id: 'ic-email', placeholder: 'you@example.com' },
h('span', null, 'ACME Account'),
h('button', {
class: 'btn btn-sm btn-outline',
style: 'margin-left:auto;',
'on:click': () => settingsModal(account),
}, '\u2699'),
]
),
h('div', { class: 'card-body' },
h('div', null, ['Registered as ', h('strong', null, esc(account.email))]),
h('div', { class: 'text-sm text-muted' }, ['CA: ', esc(account.ca)]),
),
);
}
function registerAccountModal() {
openModal((inner) => {
formModal(inner, 'Register ACME Account',
[
{ label: 'Email', id: 'reg-email', type: 'email', placeholder: 'you@example.com' },
{
label: 'CA Provider',
id: 'reg-server',
tag: 'select',
options: [['letsencrypt', "Let's Encrypt"], ['zerossl', 'ZeroSSL']],
},
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; }
const body = { domain, email: ($val('ic-email') || '').trim() || undefined };
const resp = await apiFetch('/api/certs/issue/start', {
label: 'Register',
cls: 'btn-primary',
action: 'r',
handler: async () => {
const email = ($val('reg-email') || '').trim();
if (!email) { toast('Email is required', 'error'); return; }
const server = document.getElementById('reg-server')?.value || 'letsencrypt';
const resp = await apiFetch('/api/certs/account/register', {
method: 'POST',
body,
body: { email, server },
});
if (resp.ok) {
toast('Issuance started for ' + domain, 'success');
closeModal(idx);
const rid = resp.data?.request_id;
if (rid) pollCertIssue(rid, state);
toast('ACME account registered', 'success');
closeModal();
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
toast(resp.error || 'Registration failed', 'error');
}
},
},
@@ -33,6 +77,193 @@ function issueCertModal(state) {
});
}
function settingsModal(account) {
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Account Settings</h2>'
+ '<div class="modal-body">'
+ '<div class="form-group"><label>Current Account</label><div class="text-sm">'
+ esc(account.email) + (account.ca ? ' (' + esc(account.ca) + ')' : '')
+ '</div></div>'
+ '<hr>'
+ '<div class="form-group"><label>Update Email</label>'
+ '<input id="set-email" type="email" placeholder="new@example.com"></div>'
+ '<hr>'
+ '<div class="text-danger"><strong>Danger Zone</strong></div>'
+ '<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="set-cancel">Cancel</button>'
+ '<button class="btn btn-primary" data-action="set-save">Save Email</button>'
+ '</div>';
inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => {
closeModal();
});
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => {
const email = ($val('set-email') || '').trim();
if (!email) { toast('Email is required', 'error'); return; }
const resp = await apiFetch('/api/certs/email', {
method: 'POST',
body: { email },
});
if (resp.ok) {
toast('Email updated', 'success');
closeModal();
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
}
});
inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => {
if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return;
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
if (resp.ok) {
toast('Account deactivated', 'success');
closeModal();
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
}
});
});
}
function issueCertModal(state) {
_issueState.domain = '';
_issueState.modalIdx = -1;
_issueState.account = null;
_issueState.validating = false;
(async () => {
const accountResp = await apiFetch('/api/certs/account');
_issueState.account = accountResp.ok ? accountResp.data : null;
_renderIssueModal(state);
})();
}
function _renderIssueModal(state) {
const account = _issueState.account;
const registered = account && account.registered;
const accountBadge = registered
? esc(account.email) + ' (' + esc(account.ca) + ')'
: 'No account registered';
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Issue Certificate</h2>'
+ '<div class="modal-body">'
+ '<div id="ic-account-info" class="text-sm mb-2">'
+ '<strong>Using account:</strong> ' + esc(accountBadge) + '</div>'
+ (registered
? '<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com"></div>'
: '<div class="text-warning">Register an ACME account first</div>'
)
+ '<div id="ic-vresults"></div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ (registered
? '<button class="btn btn-primary" data-action="ic-validate">Validate</button>'
: '<button class="btn btn-primary" disabled>Validate</button>'
+ '<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>'
)
+ '</div>';
_issueState.modalIdx = document.querySelectorAll('#modal-root > div').length - 1;
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx);
});
inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx);
registerAccountModal();
});
if (!registered) return;
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
if (_issueState.validating) return;
const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; }
_issueState.validating = true;
_issueState.domain = domain;
try {
const resp = await apiFetch('/api/certs/validate', {
method: 'POST',
body: { domain },
});
if (!resp.ok) {
toast(resp.error || 'Validation failed', 'error');
return;
}
_showValidate(inner, domain, resp.data.checks, resp.data.ready, state);
} finally {
_issueState.validating = false;
}
});
});
}
function _showValidate(inner, domain, checks, ready, state) {
const resultsHtml = checks.map(c => {
let cls = 'text-success';
let icon = '\u2713';
if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; }
else if (!c.passed && !c.blocking) { cls = 'text-warning'; icon = '\u26A0'; }
return '<div>' + icon + ' <strong>' + esc(c.name) + '</strong>'
+ ': ' + '<span class="' + cls + '">' + esc(c.message) + '</span></div>';
}).join('');
inner.innerHTML = '<h2 class="modal-title">Validate: ' + esc(domain) + '</h2>'
+ '<div class="modal-body">'
+ '<div class="form-group"><label>Domain</label><input id="ic-domain" value="' + esc(domain) + '"></div>'
+ '<div id="ic-vresults">' + resultsHtml + '</div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ '<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>'
+ '<button class="btn btn-primary" data-action="ic-issue"'
+ (ready ? '' : ' disabled') + '>Issue</button>'
+ '</div>';
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx);
});
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
if (_issueState.validating) return;
const domain2 = ($val('ic-domain') || '').trim();
if (!domain2) { toast('Domain is required', 'error'); return; }
_issueState.validating = true;
_issueState.domain = domain2;
try {
const resp2 = await apiFetch('/api/certs/validate', {
method: 'POST',
body: { domain: domain2 },
});
if (!resp2.ok) { toast(resp2.error || 'Validation failed', 'error'); return; }
_showValidate(inner, domain2, resp2.data.checks, resp2.data.ready, state);
} finally {
_issueState.validating = false;
}
});
inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => {
const body = { domain: _issueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', {
method: 'POST',
body,
});
if (issueResp.ok) {
toast('Issuance started for ' + _issueState.domain, 'success');
closeModal(_issueState.modalIdx);
const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid, state);
} else {
toast(issueResp.error || 'Failed', 'error');
}
});
}
async function pollCertIssue(rid, state) {
poll({
url: '/api/certs/issue/' + enc(rid),
@@ -58,6 +289,7 @@ export default definePage({
const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data);
if (guard) return guard;
const account = state.acme.data?.account || { registered: false, email: '', ca: '' };
const rows = (state.acme.data?.certs || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
@@ -88,6 +320,7 @@ export default definePage({
actions: h('button', { class: 'btn btn-primary',
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
}),
_accountCard(account),
rows.length
? Table({
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
+12 -5
View File
@@ -367,7 +367,7 @@ body {
}
/* Modal */
.modal {
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
@@ -380,12 +380,16 @@ body {
transition: opacity 0.2s, visibility 0.2s;
}
.modal.show {
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-content {
.modal-overlay.active .modal {
transform: scale(1);
}
.modal-overlay .modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
@@ -397,10 +401,13 @@ body {
transition: transform 0.2s;
}
.modal.show .modal-content {
transform: scale(1);
.modal-title {
margin: 0 0 1rem;
font-size: 1.1rem;
}
/* Toggle Switch */
.toggle-switch {
position: relative;