- Certificates can be exported for use outside Jarl: Export… on the
Certificates page (download or copy), jarl cert export, and
GET /certificates/{name}/pem?part=fullchain|cert|key|bundle.
Administrators only; key exports are audited.
- A reverse zone's missing-PTR banner lists the addresses and names
(ptr_missing_list in the zone API).
- "Used for" shows DoT/DoH for named, and "not used by Jarl" instead of
"nothing yet".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
18 KiB
JavaScript
288 lines
18 KiB
JavaScript
// 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: "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);
|
||
}
|
||
}
|
||
|
||
// 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 });
|
||
}
|