TLS certificates: Jarl obtains and renews them over ACME
Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88965096bb
commit
88f63ffb0a
43 files changed
+4584
-45
No files matched your search
@@ -0,0 +1,220 @@
|
||||
// TLS certificates (docs/certificates.md): ones Jarl obtains and renews
|
||||
// over ACME, answering the DNS-01 challenges in its own zones or at
|
||||
// Porkbun, and ones you renew yourself. Each is used for jarld's HTTPS
|
||||
// ("web") and/or named's DNS over TLS and HTTPS ("dns").
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, badge, copyBox } from "../ui.js";
|
||||
import { api, get, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
const stateKind = { ok: "ok", pending: "", obtaining: "accent", failing: "err", expiring: "warn", expired: "err" };
|
||||
let poll = null;
|
||||
|
||||
export async function render(el) {
|
||||
clearTimeout(poll);
|
||||
if (!state.me.admin) {
|
||||
mount(el, h("h1", null, "Certificates"), h("div", { class: "msg warn" }, "Only administrators can manage certificates."));
|
||||
return;
|
||||
}
|
||||
const r = await get("/certificates");
|
||||
const a = r.account;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Certificates"),
|
||||
h("div", { class: "muted" }, "TLS certificates for Jarl's web UI and for named's DNS over TLS and HTTPS. Jarl obtains them from the CA and renews them itself, answering the DNS-01 challenge in a zone it serves or through Porkbun's API while Porkbun serves the domain. A certificate you renew yourself works too: Jarl rereads it when the files change.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => createDialog(a) }, "New certificate")))];
|
||||
|
||||
if (!a.agree_tos) {
|
||||
parts.push(h("div", { class: "msg warn" },
|
||||
h("strong", null, "Jarl can't register with the CA yet. "),
|
||||
"Read the CA's terms of service, then set ", h("code", null, "acme.account.agree_tos: true"), " in /etc/jarl/jarl.yaml and restart jarld (", h("code", null, "systemctl restart jarl"), "). Certificates you renew yourself work without it."));
|
||||
}
|
||||
|
||||
parts.push(h("div", { class: "panel" }, table([
|
||||
{ label: "Name", render: (c) => h("a", { href: "#", onclick: (e) => { e.preventDefault(); details(c); } }, h("strong", null, c.name)) },
|
||||
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
|
||||
{ label: "Used for", render: (c) => [
|
||||
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
||||
c.uses.includes("dns") ? badge("named", "") : null,
|
||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||
{ label: "Status", render: (c) => {
|
||||
const st = c.issuing_since ? "obtaining" : c.status.state;
|
||||
return h("div", null, badge(st, stateKind[st]), c.status.message && !c.issuing_since ? h("div", { class: "small" }, c.status.message) : null);
|
||||
} },
|
||||
{ label: "", render: (c) => h("span", null,
|
||||
h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ",
|
||||
h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" },
|
||||
], r.certificates, { empty: "No certificates yet." })));
|
||||
|
||||
parts.push(h("div", { class: "panel" }, h("h2", null, "ACME account"), h("dl", { class: "kv" },
|
||||
h("dt", null, "CA"), h("dd", { class: "mono" }, a.directory),
|
||||
h("dt", null, "Account"), h("dd", null, a.registered ? [h("span", { class: "mono small" }, a.uri), " since ", time(a.registered_at)] : h("span", { class: "muted" }, "registered with the first certificate")),
|
||||
h("dt", null, "Email"), h("dd", null, a.email || h("span", { class: "muted" }, "none")),
|
||||
h("dt", null, "Porkbun"), h("dd", null, a.porkbun ? "can publish challenges for domains Porkbun serves" : h("span", { class: "muted" }, "no API key in jarl.yaml")),
|
||||
h("dt", null, "Seen through"), h("dd", { class: "mono small" }, (a.resolvers || []).join(" ")),
|
||||
h("dt", null, "Files for named"), h("dd", { class: "mono small" }, a.dir + "/NAME/fullchain.pem, privkey.pem")),
|
||||
h("div", { class: "small muted" }, "These are set in /etc/jarl/jarl.yaml under acme and certificates.")));
|
||||
mount(el, parts);
|
||||
|
||||
// While a certificate is being obtained, look again every few seconds.
|
||||
if (r.certificates.some((c) => c.issuing_since)) {
|
||||
poll = setTimeout(() => { if (location.hash.startsWith("#/certificates")) rerender(); }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function details(c) {
|
||||
const rows = [
|
||||
["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file],
|
||||
c.source === "acme" ? ["Asked for", c.domains.join(" ")] : null,
|
||||
c.names ? ["Names in it", c.names.join(" ")] : null,
|
||||
c.not_after ? ["Valid", fmtTime(c.not_before) + " to " + fmtTime(c.not_after)] : null,
|
||||
c.issuer ? ["Issuer", c.issuer] : null,
|
||||
c.serial ? ["Serial", c.serial] : null,
|
||||
c.source === "acme" && c.next_attempt ? [c.not_after ? "Renews" : "Next try", fmtTime(c.next_attempt)] : null,
|
||||
c.last_routes && c.last_routes.length ? ["Challenges went", c.last_routes.join("; ")] : null,
|
||||
c.last_success ? ["Last success", fmtTime(c.last_success)] : null,
|
||||
c.last_error ? ["Last error", c.last_error] : null,
|
||||
].filter(Boolean);
|
||||
modal("Certificate " + c.name, h("div", null,
|
||||
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
|
||||
c.files ? h("div", null, h("h3", null, "For named"),
|
||||
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
|
||||
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
||||
}
|
||||
|
||||
async function issue(c) {
|
||||
try {
|
||||
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
|
||||
if (r.error) toast(r.error, "err", 10000);
|
||||
else toast(c.source === "acme" ? "Obtaining " + c.name + " from the CA…" : "Reread " + c.name);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 10000); }
|
||||
}
|
||||
|
||||
async function del(c) {
|
||||
const msg = "Delete certificate " + c.name + "?" +
|
||||
(c.uses.includes("dns") ? " Its files for named are removed: take it out of any tls block first." : "") +
|
||||
(c.serving ? " Jarl's web UI goes back to tls.cert_file in jarl.yaml, or to plain HTTP." : "");
|
||||
if (!await confirmDialog("Delete certificate", msg, "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/certificates/" + encodeURIComponent(c.name));
|
||||
toast("Deleted " + c.name);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// usesBoxes returns checkboxes for a certificate's uses.
|
||||
function usesBoxes(uses) {
|
||||
const web = checkbox("Jarl's web UI and API (HTTPS)", uses.includes("web"));
|
||||
const dns = checkbox("named: DNS over TLS and HTTPS", uses.includes("dns"));
|
||||
return { el: h("div", null, h("div", null, web), h("div", null, dns)), value: () => [web.input.checked ? "web" : null, dns.input.checked ? "dns" : null].filter(Boolean) };
|
||||
}
|
||||
|
||||
function challengeSelect(v, porkbun) {
|
||||
return select([["auto", "Work it out at every renewal (recommended)"], ["jarl", "A zone this server serves"], ["porkbun", "Porkbun's API" + (porkbun ? "" : " (no key in jarl.yaml)")]], v || "auto");
|
||||
}
|
||||
|
||||
const keyTypes = [["ec256", "ECDSA P-256 (recommended)"], ["ec384", "ECDSA P-384"], ["rsa2048", "RSA 2048 (old clients)"], ["rsa3072", "RSA 3072"], ["rsa4096", "RSA 4096"]];
|
||||
|
||||
async function createDialog(a) {
|
||||
const name = input({ placeholder: "e.g. ns1", pattern: "[a-z0-9][a-z0-9_-]*" });
|
||||
const src = select([["acme", "Jarl obtains and renews it"], ["file", "I renew it myself (files on the server)"]], "acme");
|
||||
const domains = textarea("", { rows: 3, placeholder: "ns1.adbraun.lol\n*.adbraun.lol", class: "code" });
|
||||
const challenge = challengeSelect("auto", a.porkbun);
|
||||
const keyType = select(keyTypes, "ec256");
|
||||
const certFile = input({ placeholder: "/etc/ssl/ns1/fullchain.pem", class: "code" });
|
||||
const keyFile = input({ placeholder: "/etc/ssl/ns1/privkey.pem", class: "code" });
|
||||
const uses = usesBoxes([]);
|
||||
const comment = input();
|
||||
const checkOut = h("div");
|
||||
const err = h("div");
|
||||
const acmeFields = h("div", { class: "form-grid" },
|
||||
field("Names", domains, "One per line. *.example.com for a wildcard (which also needs example.com if you want that name too)."),
|
||||
field("Challenge", challenge, "Where the DNS-01 challenge goes. Work it out: Porkbun while the domain's name servers are Porkbun's, this server once they are its own."),
|
||||
field("Key", keyType));
|
||||
const fileFields = h("div", { class: "form-grid" },
|
||||
field("Certificate", certFile, "The full chain (certificate and intermediates), PEM. jarld must be able to read it."),
|
||||
field("Key", keyFile, "Its private key, PEM."));
|
||||
// display, not hidden: .form-grid sets display itself.
|
||||
const sync = () => {
|
||||
acmeFields.style.display = checkRow.style.display = src.value === "acme" ? "" : "none";
|
||||
fileFields.style.display = src.value === "file" ? "" : "none";
|
||||
};
|
||||
const body = () => {
|
||||
const b = { name: name.value.trim(), source: src.value, uses: uses.value(), comment: comment.value };
|
||||
if (src.value === "acme") Object.assign(b, { domains: lines(domains.value), challenge: challenge.value, key_type: keyType.value });
|
||||
else Object.assign(b, { cert_file: certFile.value.trim(), key_file: keyFile.value.trim() });
|
||||
return b;
|
||||
};
|
||||
const check = h("button", { type: "button", class: "small", onclick: async () => {
|
||||
mount(checkOut, h("div", { class: "muted small" }, "Looking the names up as the CA would…"));
|
||||
try {
|
||||
const r = await api("POST", "/certificates/check", { body: { domains: lines(domains.value), challenge: challenge.value } });
|
||||
mount(checkOut, routesTable(r.routes));
|
||||
} catch (e) { mount(checkOut, errorBox(e)); }
|
||||
} }, "Check the names");
|
||||
const checkRow = h("div", { class: "check-names" }, check, checkOut);
|
||||
src.addEventListener("change", sync);
|
||||
sync();
|
||||
await modal("New certificate", h("div", null,
|
||||
h("div", { class: "form-grid" },
|
||||
field("Name", name, "Lowercase letters, digits, - and _. Also the directory of its files for named."),
|
||||
field("Source", src)),
|
||||
acmeFields, checkRow,
|
||||
fileFields,
|
||||
h("div", { class: "form-grid" }, field("Used for", uses.el, "Only one certificate can serve the web UI. Taking it on switches jarld to HTTPS on its ports at once (plain HTTP from this server itself keeps working)."), field("Comment", comment)),
|
||||
err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
try {
|
||||
const r = await api("POST", "/certificates", { body: body() });
|
||||
toast(r.certificate.issuing_since ? "Obtaining " + r.certificate.name + " from the CA…" : "Added " + r.certificate.name);
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
|
||||
function routesTable(routes) {
|
||||
return table([
|
||||
{ label: "Name", render: (r) => h("span", { class: "mono" }, r.domain) },
|
||||
{ label: "Goes to", render: (r) => r.error ? badge("nowhere", "err") : badge(r.route === "jarl" ? "this server" : "Porkbun", "ok") },
|
||||
{ label: "Why", render: (r) => h("div", { class: "small" }, r.error || r.why,
|
||||
r.target && r.target !== r.record ? h("div", { class: "muted" }, "via CNAME to " + r.target.replace(/\.$/, "")) : null) },
|
||||
], routes);
|
||||
}
|
||||
|
||||
async function editDialog(c, a) {
|
||||
const domains = textarea((c.domains || []).join("\n"), { rows: 3, class: "code" });
|
||||
const challenge = challengeSelect(c.challenge, a.porkbun);
|
||||
const keyType = select(keyTypes, c.key_type);
|
||||
const certFile = input({ value: c.cert_file || "", class: "code" });
|
||||
const keyFile = input({ value: c.key_file || "", class: "code" });
|
||||
const uses = usesBoxes(c.uses);
|
||||
const comment = input({ value: c.comment || "" });
|
||||
const err = h("div");
|
||||
const acme = c.source === "acme";
|
||||
await modal("Edit certificate " + c.name, h("div", null,
|
||||
acme ? h("div", { class: "form-grid" },
|
||||
field("Names", domains, "New names or a new key make Jarl obtain it again soon."),
|
||||
field("Challenge", challenge),
|
||||
field("Key", keyType))
|
||||
: h("div", { class: "form-grid" }, field("Certificate", certFile), field("Key", keyFile)),
|
||||
h("div", { class: "form-grid" }, field("Used for", uses.el), field("Comment", comment)),
|
||||
err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Save", kind: "primary", onclick: async () => {
|
||||
const b = { uses: uses.value(), comment: comment.value };
|
||||
if (acme) Object.assign(b, { domains: lines(domains.value), challenge: challenge.value, key_type: keyType.value });
|
||||
else Object.assign(b, { cert_file: certFile.value.trim(), key_file: keyFile.value.trim() });
|
||||
try {
|
||||
await api("PATCH", "/certificates/" + encodeURIComponent(c.name), { body: b });
|
||||
toast("Saved " + c.name);
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
Reference in new issue
Block a user