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:
1 parent
d0ee2c8844
commit
7b3f0f764c
32 files changed
+2544
-27
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user