feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)

- Add lib/state.py: in-memory state store with subsystem collectors
  (firewall, dnsmasq, nginx, acme, wireguard)
- Refactor all handlers: read from state on GET, call refresh_state()
  after mutations instead of invoking subprocesses per request
- daemon/server.py: add refresh_state(), /status/all, /status/refresh;
  populate state at startup
- webui/api/certs.py: async step-by-step ACME issuance (validate,
  issue with request_id, poll status) replacing blocking endpoint
- webui/server.py: render pages from state instead of direct lib calls
- Update templates, JS for async cert issuance with polling UI
- Update tests for state-based mocking; add test_state.py
- Fix SIM105 lint issue (contextlib.suppress)
- Add TODO.md with certificate issuance issue tracking

Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
This commit is contained in:
2026-05-30 05:45:40 +00:00
parent c091063248
commit 7beba44b4b
19 changed files with 1957 additions and 986 deletions
+43 -7
View File
@@ -35,24 +35,60 @@ def cert_details(domain: str):
return _error(str(exc), 500)
@bp.route("/issue", methods=["POST"])
def issue_bp():
@bp.route("/validate", methods=["POST"])
def validate():
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
try:
result = post("/acme/validate", {"domain": domain})
return _ok(result)
except BadRequest as exc:
logger.info("Validation rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to validate cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/issue/start", methods=["POST"])
def issue_start():
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
webroot = body.get("webroot")
email = body.get("email", "").strip() or None
webroot = body.get("webroot")
try:
logger.info("Certificate issuance requested for '%s' via API", domain)
post("/acme/issue", {"domain": domain, "webroot": webroot, "email": email})
logger.info("Certificate issued for '%s'", domain)
return _ok(None)
result = post(
"/acme/issue", {"domain": domain, "webroot": webroot, "email": email}
)
logger.info(
"Certificate issuance started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to issue cert for '%s': %s", domain, exc)
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/issue/<request_id>", methods=["GET"])
def issue_status(request_id: str):
try:
result = get("/acme/issue/status", {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Issuance request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get issuance status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
+88 -37
View File
@@ -5,12 +5,16 @@ Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
and enforces basic authentication before proxying to this port.
"""
import contextlib
import importlib
import logging
import os
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from flask import Flask, render_template, request
@@ -41,6 +45,26 @@ logger.info(
logger.info("Project directory: %s", PROJECT_DIR)
logger.info("Process ID: %d", os.getpid())
_reloading = False
def _sighup_handler(signum, frame):
global _reloading
if _reloading:
return
_reloading = True
logger.info("Received SIGHUP, reloading modules...")
for mod_name, mod in sys.modules.items():
if mod_name.startswith("webui.") or mod_name.startswith("lib."):
with contextlib.suppress(Exception):
importlib.reload(mod)
logger.info("Modules reloaded, sending SIGTERM to restart under systemd...")
signal.signal(signal.SIGTERM, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGTERM)
signal.signal(signal.SIGHUP, _sighup_handler)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
@@ -184,21 +208,38 @@ def _get_service_status(dnsmasq_info, wg_info):
return services
def _fw_config_get():
def _fw_config_get() -> dict[str, Any]:
"""Read firewall config via daemon."""
return get("/firewall/config")
def _load_status_all() -> dict[str, Any]:
"""Load all system state in one call."""
return get("/status/all")
@app.route("/")
def root_redirect():
from flask import redirect, url_for
return redirect(url_for("dashboard"))
@app.route("/dashboard")
def dashboard():
active_zones = _safely(
lambda: {k: v for k, v in get("/firewall/zones").get("active", {}).items()}, {}
)
interfaces = _safely(lambda: get("/firewall/interfaces"), [])
dnsmasq = _safely(lambda: get("/dnsmasq/status"), {})
domains = _safely(lambda: get("/nginx/domains"), [])
certs = _safely(lambda: get("/acme/list"), [])
wg = _safely(lambda: get("/wireguard/status"), {})
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
dm_state = all_status.get("dnsmasq", {}) or {}
ng_state = all_status.get("nginx", {}) or {}
ac_state = all_status.get("acme", {}) or {}
wg_state = all_status.get("wireguard", {}) or {}
active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
interfaces = fw_state.get("interfaces", [])
dnsmasq = dm_state.get("status", {})
domains = ng_state.get("domains", [])
certs = ac_state.get("certs", [])
wg = wg_state.get("status", {})
return render_template(
"dashboard.html",
@@ -210,41 +251,44 @@ def dashboard():
wg_status=wg,
services=_get_service_status(dnsmasq, wg),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/interfaces")
def interfaces_page():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"interfaces.html",
interfaces=_safely(lambda: get("/firewall/interfaces"), []),
zones=_safely(lambda: get("/firewall/zones").get("available", []), []),
interfaces=fw_state.get("interfaces", []),
zones=fw_state.get("active_zones", {}).keys() or [],
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/zones")
def zones_page():
firewall_config = _safely(_fw_config_get, {})
firewall_pending = _safely(lambda: get("/firewall/config/pending"), {})
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"zones.html",
zones=_safely(lambda: get("/firewall/zones/all"), []),
services=_safely(lambda: get("/firewall/services"), []),
firewall_config=firewall_config,
firewall_pending=firewall_pending,
zones=list(fw_state.get("zones", {}).values()),
services=fw_state.get("available_services", []),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/rules")
def rules_page():
zones = list(_safely(lambda: get("/firewall/zones").get("active", {}).keys(), []))
raw = _safely(_fw_config_get, {})
rules = {}
for zname, zcfg in raw.get("zones", {}).items():
rr = zcfg.get("rich_rules", [])
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
zones = list(fw_state.get("zones", {}).keys())
rules: dict[str, list[str]] = {}
for zname, zcfg in fw_state.get("zones", {}).items():
rr = zcfg.get("rich-rules", [])
if rr:
rules[zname] = rr
return render_template("rules.html", zones=zones, rules=rules or None)
@@ -252,46 +296,53 @@ def rules_page():
@app.route("/nat")
def nat_page():
return render_template(
"nat.html", zones=_safely(lambda: get("/firewall/zones/all"), [])
)
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
@app.route("/dhcp")
def dhcp_page():
all_status = _safely(_load_status_all, {})
dm_state = all_status.get("dnsmasq", {}) or {}
return render_template(
"dhcp.html",
config=_safely(lambda: get("/dnsmasq/config"), {}),
status=_safely(lambda: get("/dnsmasq/status"), {}),
leases=_safely(lambda: get("/dnsmasq/leases"), []),
config=dm_state.get("config", {}),
status=dm_state.get("status", {}),
leases=dm_state.get("leases", []),
)
@app.route("/proxy")
def proxy_page():
all_status = _safely(_load_status_all, {})
ng_state = all_status.get("nginx", {}) or {}
return render_template(
"proxy.html",
domains=_safely(lambda: get("/nginx/domains"), []),
config=_safely(lambda: get("/nginx/config"), {}),
domains=ng_state.get("domains", []),
config=ng_state.get("config", {}),
)
@app.route("/certs")
def certs_page():
email_data = _safely(lambda: get("/acme/email"), {"email": ""})
all_status = _safely(_load_status_all, {})
ac_state = all_status.get("acme", {}) or {}
return render_template(
"certs.html",
certs=_safely(lambda: get("/acme/list"), []),
email=email_data.get("email", ""),
certs=ac_state.get("certs", []),
email=ac_state.get("email", ""),
)
@app.route("/wireguard")
def wireguard_page():
all_status = _safely(_load_status_all, {})
wg_state = all_status.get("wireguard", {}) or {}
return render_template(
"wireguard.html",
config=_safely(lambda: get("/wireguard/config"), {}),
status=_safely(lambda: get("/wireguard/status"), {}),
config=wg_state.get("config", {}),
status=wg_state.get("status", {}),
)
+175 -1
View File
@@ -29,7 +29,7 @@ const closeModal = (id) => {
};
// Tab switching
const switchTab = (tabName) => {
let switchTab = (tabName) => {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + tabName).classList.add('active');
@@ -321,3 +321,177 @@ const escHtml = (s) => {
const escAttr = (s) => {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
};
// ─── Certificate Issue Wizard ────────────────────────────────────────
let _issuePollHandle = null;
let _issueRequestId = null;
function closeIssueWizard() {
if (_issuePollHandle) {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
}
_issueRequestId = null;
resetIssueWizard();
closeModal('issue-cert-modal');
}
function resetIssueWizard() {
document.getElementById('cert-wizard-input').style.display = '';
document.getElementById('cert-wizard-progress').style.display = 'none';
document.getElementById('cert-check-results').style.display = 'none';
document.getElementById('cert-check-btn').style.display = '';
document.getElementById('cert-issue-btn').style.display = 'none';
document.getElementById('cert-close-progress').style.display = 'none';
}
function validateCertIssue() {
const domain = document.getElementById('cert-domain').value.trim();
if (!domain) {
showErrorToast('Domain is required');
return;
}
const email = document.getElementById('cert-email').value.trim() || undefined;
const checkBtn = document.getElementById('cert-check-btn');
checkBtn.disabled = true;
checkBtn.textContent = 'Checking...';
fetch('/api/certs/validate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domain, email })
})
.then(r => r.json())
.then(data => {
checkBtn.disabled = false;
checkBtn.textContent = 'Check';
const result = data.ok ? data.data : data;
renderChecks(result.checks);
if (result.ready) {
document.getElementById('cert-check-btn').style.display = 'none';
document.getElementById('cert-issue-btn').style.display = '';
} else {
document.getElementById('cert-issue-btn').style.display = 'none';
}
})
.catch(e => {
checkBtn.disabled = false;
checkBtn.textContent = 'Check';
showErrorToast('Validation failed: ' + e.message);
});
}
function renderChecks(checks) {
const container = document.getElementById('cert-checks-list');
const resultsDiv = document.getElementById('cert-check-results');
resultsDiv.style.display = '';
container.innerHTML = checks.map(c => {
let icon, badge;
if (c.passed) {
icon = '✓';
badge = c.blocking ? 'badge-success' : 'badge-info';
} else {
icon = '✗';
badge = 'badge-danger';
}
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;">' +
'<span class="badge ' + badge + '">' + icon + '</span>' +
'<span>' + escHtml(c.name).replace(/_/g, ' ') + '</span>' +
'<span class="text-muted" style="flex:1;text-align:right;">' + escHtml(c.message || '') + '</span>' +
'</div>';
}).join('');
}
function startCertIssue() {
const domain = document.getElementById('cert-domain').value.trim();
const email = document.getElementById('cert-email').value.trim() || undefined;
document.getElementById('cert-wizard-input').style.display = 'none';
document.getElementById('cert-wizard-progress').style.display = '';
document.getElementById('cert-steps-list').innerHTML = '<div class="text-muted text-sm" style="margin:16px 0;">Starting certificate issuance…</div>';
fetch('/api/certs/issue/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domain, email })
})
.then(r => r.json())
.then(data => {
const result = data.ok ? data.data : data;
_issueRequestId = result.request_id;
if (!result.request_id) throw new Error('No request_id returned');
// If issuance already exists for this domain, follow the existing request
startIssuePoll(result.request_id);
})
.catch(e => {
showErrorToast('Failed to start issuance: ' + e.message);
// Fall back to input phase
document.getElementById('cert-wizard-input').style.display = '';
document.getElementById('cert-wizard-progress').style.display = 'none';
});
}
function startIssuePoll(requestId) {
_issueRequestId = requestId;
_issuePollHandle = setInterval(() => pollIssueStatus(requestId), 2000);
// Also poll immediately
pollIssueStatus(requestId);
}
function pollIssueStatus(requestId) {
fetch('/api/certs/issue/' + encodeURIComponent(requestId))
.then(r => r.json())
.then(data => {
const result = data.ok ? data.data : data;
renderIssueSteps(result.steps, result.status);
if (result.status === 'completed') {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
document.getElementById('cert-close-progress').style.display = '';
showSuccessToast('Certificate issued for ' + result.domain);
} else if (result.status === 'failed') {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
// Show failed — user can see which step failed
document.getElementById('cert-close-progress').style.display = '';
showErrorToast('Certificate issuance failed for ' + result.domain);
}
})
.catch(e => {
// Don't poll on error — but keep trying since request might still be running
});
}
function renderIssueSteps(steps, status) {
const container = document.getElementById('cert-steps-list');
if (!steps || !steps.length) {
container.innerHTML = '<div class="text-muted text-sm">Pending…</div>';
return;
}
container.innerHTML = steps.map(s => {
let icon;
if (s.status === 'done') icon = '<span class="status-dot status-up"></span>';
else if (s.status === 'running') icon = '<span class="status-dot status-pending"></span>';
else if (s.status === 'error') icon = '<span class="status-dot status-down"></span>';
else icon = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border);margin-right:6px;"></span>';
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">' +
icon +
'<span>' + escHtml(s.label) + '</span>' +
(s.status === 'running' ? '<span class="text-muted text-sm">(in progress…)</span>' :
s.status === 'error' ? '<span class="badge badge-danger" style="margin-left:auto;">' + escHtml(s.message || 'failed') + '</span>' :
'<span class="badge badge-success" style="margin-left:auto;">done</span>') +
'</div>';
}).join('');
if (status === 'completed') {
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
}
}
+28 -10
View File
@@ -7,7 +7,7 @@
<h1>Certificates</h1>
<div class="subtitle">SSL/TLS certificate management</div>
</div>
<button class="btn btn-primary" onclick="openModal('issue-cert-modal')">+ Issue New Certificate</button>
<button class="btn btn-primary" onclick="resetIssueWizard(); openModal('issue-cert-modal')">+ Issue New Certificate</button>
</div>
<div class="card">
@@ -53,24 +53,42 @@
</table>
</div>
<!-- Issue Certificate Modal -->
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
<div class="modal">
<!-- Issue Certificate Modal — Phase 1: Validate -->
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeIssueWizard()">
<div class="modal" style="min-width:480px;">
<h2>Issue New Certificate</h2>
<form hx-post="/api/certs/issue" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('issue-cert-modal'); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Certificate issuance started'); }">
<!-- Phase 1: Input + Pre-flight Checks -->
<div id="cert-wizard-input">
<div class="form-group">
<label for="cert-domain">Domain</label>
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
<input type="text" id="cert-domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="cert-email">Contact Email</label>
<input type="email" id="cert-email" name="email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
<input type="email" id="cert-email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
</div>
<!-- Pre-flight validation results (shown after Check) -->
<div id="cert-check-results" style="display:none;">
<div class="section-title" style="margin-top:16px;">Pre-flight Checks</div>
<div id="cert-checks-list"></div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('issue-cert-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Issue</button>
<button type="button" class="btn btn-outline" onclick="closeIssueWizard()">Cancel</button>
<button type="button" id="cert-check-btn" class="btn btn-primary" onclick="validateCertIssue()">Check</button>
<button type="button" id="cert-issue-btn" class="btn btn-primary" style="display:none;" onclick="startCertIssue()">Issue</button>
</div>
</form>
</div>
<!-- Phase 2: Step progress -->
<div id="cert-wizard-progress" style="display:none;">
<div id="cert-steps-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" id="cert-close-progress" style="display:none;" onclick="closeIssueWizard(); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts);">Done</button>
</div>
</div>
</div>
</div>
{% endblock %}
+27 -15
View File
@@ -89,36 +89,34 @@
var refreshInterval = {{ (refresh_interval | default(15)) }};
var currentTab = 'journal';
function loadTabEl(el) {
var url = el.getAttribute('hx-get');
if (!url) return;
el.textContent = 'Loading...';
fetch(url).then(function(r) { return r.text(); })
.then(function(html) { el.innerHTML = html; })
.catch(function() { el.innerHTML = '<div class="log-line">(failed to load log)</div>'; });
}
function setActivePolling() {
document.querySelectorAll('.log-viewer').forEach(function(el) {
htmx.abort(el);
if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
}
if (typeof htmx !== 'undefined') htmx.process(document.body);
}
function loadActiveTab() {
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
htmx.ajax('GET', activeEl);
loadTabEl(activeEl);
}
}
var origSwitchTab = switchTab;
switchTab = function(tabName) {
currentTab = tabName;
if (typeof origSwitchTab === 'function') {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
function toggleAutoRefresh() {
var toggle = document.getElementById('auto-refresh-toggle');
if (toggle.checked) {
@@ -126,7 +124,7 @@ function toggleAutoRefresh() {
loadActiveTab();
} else {
document.querySelectorAll('.log-viewer').forEach(function(el) {
htmx.abort(el);
if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
}
@@ -135,5 +133,19 @@ function toggleAutoRefresh() {
document.addEventListener('DOMContentLoaded', function() {
loadActiveTab();
});
window.addEventListener('load', function() {
var origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
switchTab = function(tabName) {
currentTab = tabName;
if (origSwitchTab) {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
});
</script>
{% endblock %}