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>
87 lines
4.7 KiB
JavaScript
87 lines
4.7 KiB
JavaScript
// 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));
|
|
}
|