// 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. Housecarl can get its own certificate from Let's Encrypt, with Jarl publishing the challenge: set tls.acme and jarl in housecarl.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 housecarl.yaml and restart housecarld 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)); }