The web UI's certificate from Let's Encrypt, through Jarl

tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-14 15:46:36 +01:00
1 parent d0ee2c8844
commit 7b3f0f764c
32 files changed
+2544 -27

No files matched your search

+4 -1
View File
@@ -13,6 +13,7 @@ import * as history from "./pages/history.js";
import * as audit from "./pages/audit.js";
import * as users from "./pages/users.js";
import * as account from "./pages/account.js";
import * as certificate from "./pages/certificate.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
@@ -27,6 +28,7 @@ const routes = [
{ pattern: /^audit$/, page: audit, nav: "audit" },
{ pattern: /^users$/, page: users, nav: "users" },
{ pattern: /^account$/, page: account, nav: "account" },
{ pattern: /^certificate$/, page: certificate, nav: "certificate" },
];
// The top bar. ["key", "#/href", "Label", permission?]
@@ -39,6 +41,7 @@ const navItems = [
["history", "#/history", "History", "config"],
{ menu: "Server", items: [
["settings", "#/settings", "Settings and options", "config"],
["certificate", "#/certificate", "Certificate", "admin"],
["audit", "#/audit", "Audit log", "audit"],
] },
];
@@ -116,7 +119,7 @@ function footer(b) {
}
function renderNav() {
const allowed = (n) => !n[3] || can(n[3], false);
const allowed = (n) => !n[3] || (n[3] === "admin" ? state.me.admin : can(n[3], false));
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
mount(navEl, navItems.map((n) => {
if (!n.menu) return allowed(n) ? link(n) : null;
@@ -0,0 +1,86 @@
// The web UI's certificate: obtained through Jarl (tls.acme), its state,
// the last check, and buttons to test and renew.
import { h, mount, table, time, fmtTime, badge, errorBox, toast } from "../ui.js";
import { get, api } from "../api.js";
import { onLeave, rerender } from "../app.js";
const stateBadge = {
ok: ["valid", "ok"], working: ["working…", "accent"], error: ["failing", "err"], none: ["none yet", ""],
};
export async function render(el) {
const r = await get("/certificate");
if (r.source !== "acme") {
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"))),
h("div", { class: "panel" },
r.source === "file"
? h("p", null, "The web UI uses the certificate in tls.cert_file, which you renew yourself.")
: h("p", null, "The web UI is plain HTTP. Steward can get its own certificate from Let's Encrypt, with Jarl publishing the challenge: set tls.acme and jarl in steward.yaml (see docs/certificates.md)."),
));
return;
}
const st = r.status;
const out = h("div");
const checkBox = h("div");
const busy = st.state === "working";
const testBtn = h("button", { disabled: busy, onclick: async () => {
testBtn.disabled = true;
mount(checkBox, h("div", { class: "msg info" }, h("span", { class: "spinner" }), "Checking: writing a test record through Jarl and waiting until it is seen (up to a few minutes)…"));
try {
const c = await api("POST", "/certificate/check");
mount(checkBox, checkView(c));
} catch (e) { mount(checkBox, errorBox(e)); }
testBtn.disabled = false;
} }, "Test");
const renewBtn = h("button", { class: "primary", disabled: busy, onclick: async () => {
try {
await api("POST", "/certificate/renew");
toast("Getting a new certificate…");
setTimeout(rerender, 1500);
} catch (e) { toast(e.message, "err", 8000); }
} }, st.not_after ? "Renew now" : "Get the certificate now");
const [label, kind] = stateBadge[st.state] || [st.state, ""];
mount(out,
h("div", { class: "panel" },
h("dl", { class: "kv" },
h("dt", null, "Name"), h("dd", null, h("strong", null, st.domain), " ", badge(label, kind)),
h("dt", null, "From"), h("dd", null, st.staging ? ["Let's Encrypt staging ", badge("not trusted by browsers", "warn")] : st.directory),
st.not_after ? [h("dt", null, "Valid until"), h("dd", null, fmtTime(st.not_after), " (", days(st.not_after), "), issued by ", st.issuer)] : null,
st.renew_at ? [h("dt", null, "Renews"), h("dd", null, fmtTime(st.renew_at))] : null,
st.last_attempt ? [h("dt", null, "Last attempt"), h("dd", null, time(st.last_attempt))] : null,
st.next_attempt && !busy ? [h("dt", null, "Next check"), h("dd", null, time(st.next_attempt))] : null),
st.last_error ? h("div", { class: "msg err" }, st.last_error) : null,
st.staging && st.state === "ok" ? h("div", { class: "msg warn" }, "This certificate is from Let's Encrypt's staging service, to prove everything works. Browsers warn about it. Set tls.acme.directory: letsencrypt in steward.yaml and restart stewardd for a trusted one.") : null,
h("div", { class: "actions mt" }, testBtn, renewBtn,
h("span", { class: "muted small" }, "Test runs the check without asking Let's Encrypt; a certificate order always runs it first."))),
checkBox);
if (st.check) mount(checkBox, checkView(st.check));
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"),
h("div", { class: "muted" }, "The web UI's certificate, obtained from the CA with Jarl publishing the DNS-01 challenge."))), out);
if (busy) {
const t = setTimeout(rerender, 3000);
onLeave(() => clearTimeout(t));
}
}
// days says how long until a time: "in 89 days", "in 5 hours".
function days(iso) {
const ms = new Date(iso) - Date.now();
if (ms <= 0) return "ended";
const d = Math.floor(ms / 86400000);
return d >= 2 ? "in " + d + " days" : "in " + Math.max(1, Math.round(ms / 3600000)) + " hours";
}
function checkView(c) {
const mark = { ok: ["ok", "ok"], fail: ["failed", "err"], warn: ["warning", "warn"], skip: ["skipped", ""] };
return h("div", { class: "panel" },
h("h2", null, "Check, ", time(c.time), " ", c.ok ? badge("passed", "ok") : badge("failed", "err")),
table([
{ label: "Step", render: (s) => h("strong", null, s.name) },
{ label: "", render: (s) => badge(...(mark[s.status] || [s.status, ""])) },
{ label: "What happened", render: (s) => [s.detail,
(s.queries || []).length ? h("details", { class: "small", open: s.status === "fail" },
h("summary", null, "DNS questions (" + s.queries.length + ")"),
h("pre", { class: "wrap" }, s.queries.join("\n"))) : null] },
], c.steps));
}
+1
View File
@@ -355,3 +355,4 @@ td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; }
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }