Files
adamandClaude Opus 5.5 9db76a113c Certificates for other hosts: jarl cert get
jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.

- Certificates have an owner user; another user can't take the name.
  An existing one is obtained again only when a name is added or
  removed. A new certificates grant (names=) says which names a user
  may have; it is never part of "*". Users may own
  certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
  delete theirs; keys only over HTTPS, the socket or loopback; every
  download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
  saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00

298 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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") ? namedUse(c) : null,
!c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] },
{ label: "Owner", render: (c) => c.owner ? h("span", null, c.owner, pulledBy(c)) : h("span", { class: "muted" }, "Jarl") },
{ 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"), " ",
c.not_after ? [h("button", { class: "small", title: "Download it to use in another program", onclick: () => exportDialog(c) }, "Export…"), " "] : null,
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);
}
}
// pulledBy says who downloaded a certificate for another host, and
// whether they have the current one.
function pulledBy(c) {
const ps = c.pulled_by || [];
if (!ps.length) return h("div", { class: "small muted" }, "not downloaded yet");
return ps.map((p) => h("div", { class: "small " + (p.serial === c.serial ? "muted" : "warn-text"), title: p.who + " from " + (p.ip || "?") + ", " + fmtTime(p.at) },
p.who.replace(/^token /, "") + ", " + fmtTime(p.at) + (p.serial === c.serial ? "" : " (old: not fetched since the renewal)")));
}
// namedUse is what named uses a certificate for: DoT and DoH, with the
// addresses in the tooltip (the details list them).
function namedUse(c) {
const uses = c.named_use || [];
if (!uses.length) return h("span", { class: "muted", title: "Encrypted DNS sets up named's DNS over TLS and HTTPS" }, "named: not set up");
const kinds = [["DNS over TLS", "DoT"], ["DNS over HTTPS", "DoH"]].filter(([long]) => uses.some((u) => u.startsWith(long)));
return h("span", { title: uses.join("\n") }, kinds.map(([, short]) => [badge(short, ""), " "]));
}
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"),
(c.named_use || []).length ? h("ul", { class: "small" }, c.named_use.map((u) => h("li", null, u))) : h("p", { class: "small muted" }, "named doesn't use it yet (Encrypted DNS sets that up)."),
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 });
}
// exportDialog downloads a certificate's files to use elsewhere.
function exportDialog(c) {
const parts = [
["fullchain", "fullchain.pem", "the certificate and its chain: what most programs want"],
["cert", "cert.pem", "the certificate alone"],
["key", "privkey.pem", "the private key"],
["bundle", "bundle.pem", "the key, then the chain, in one file (HAProxy)"],
];
modal("Export certificate " + c.name, h("div", null,
c.source === "acme"
? h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). Jarl renews the certificate, but not your copy: export it again after each renewal",
c.next_attempt ? " (next: " + fmtTime(c.next_attempt) + ")" : "", ", or have the program read the files Jarl writes for named (use it for named).")
: h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). You renew this one yourself, so a program on this server can read your files directly: ", h("code", null, c.cert_file), "."),
h("table", { class: "small" }, parts.map(([part, file, what]) => h("tr", null,
h("td", null, h("button", { class: "small" + (part === "fullchain" ? " primary" : ""), title: "Download " + file, onclick: () => download(c, part, file) }, file), " ",
h("button", { class: "small", title: "Copy " + file + " to the clipboard", onclick: () => copyPEM(c, part, file) }, "Copy")),
h("td", { class: part === "key" || part === "bundle" ? "" : "muted" }, what, part === "key" || part === "bundle" ? h("span", { class: "muted" }, " (audited)") : null)))),
h("p", { class: "small muted" }, "CLI: ", h("code", null, "jarl cert export " + c.name + " --dir DIR"))), [{ label: "Close", value: null }]);
}
// fetchPEM gets one part of a certificate, after asking when it holds the
// private key (verb: "Download" or "Copy"). null when not confirmed.
async function fetchPEM(c, part, verb) {
if ((part === "key" || part === "bundle") && !await confirmDialog(verb + " the private key",
"Whoever has the key of " + c.name + " can pose as " + (c.names || c.domains || []).join(", ") + " until the certificate expires. Keep it as safe as the server keeps it" +
(verb === "Copy" ? ", and clear it from the clipboard when you're done (clipboard managers keep a history)" : "") + ". This is recorded in the audit log.", verb)) return null;
const resp = await fetch("/api/v1/certificates/" + encodeURIComponent(c.name) + "/pem?part=" + part, { credentials: "same-origin" });
if (!resp.ok) {
let msg = "HTTP " + resp.status;
try { msg = (await resp.json()).error.message; } catch { /* not JSON */ }
throw new Error(msg);
}
return resp.text();
}
async function copyPEM(c, part, file) {
try {
const text = await fetchPEM(c, part, "Copy");
if (text === null) return;
if (!navigator.clipboard) throw new Error("the browser allows copying only over HTTPS (or from localhost); download it instead");
await navigator.clipboard.writeText(text);
toast("Copied " + file);
} catch (e) { toast("Copy failed: " + e.message, "err", 8000); }
}
async function download(c, part, file) {
try {
const text = await fetchPEM(c, part, "Download");
if (text === null) return;
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "application/x-pem-file" })), download: c.name + "-" + file });
document.body.append(a);
a.click();
a.remove();
} catch (e) { toast(e.message, "err", 8000); }
}
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 });
}