Certificate export, missing PTR list, simpler "Used for"
- 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>
This commit is contained in:
1 parent
57012fece8
commit
b4bb99b482
16 files changed
+352
-12
No files matched your search
@@ -32,10 +32,9 @@ export async function render(el) {
|
||||
{ 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", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
|
||||
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
|
||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||
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) => {
|
||||
@@ -45,6 +44,7 @@ export async function render(el) {
|
||||
{ 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." })));
|
||||
|
||||
@@ -64,6 +64,15 @@ export async function render(el) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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],
|
||||
@@ -85,6 +94,62 @@ function details(c) {
|
||||
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: {} });
|
||||
|
||||
Reference in new issue
Block a user