Add DNSSEC signing, key backups and Porkbun support

Zones get a DNSSEC setting: named signs them with BIND's default (or a
custom) dnssec-policy and inline signing, with keys in a per-view key
directory. Jarl shows the keys, the DS record and when it is safe to
publish it, checks the parent's DS records (check-ds, which also tells
named), and refuses steps that would break a signed domain: switching
straight to unsigned, finishing a turn-off too early, deleting or moving
a signed zone, TTLs above max-zone-ttl. Turning off goes through the
insecure policy; wipe starts over once the parent has no DS.

Backups store the DNSSEC keys and restore puts them back for named; the
backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's
serial above named's whenever it writes an older one (after a restore).
The serials check compares the signed serial.

With a Porkbun API key in jarl.yaml, the registrar's name servers, glue
records and DS records can be shown and changed, each change checked
first (dry run, force to override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 21:19:04 +00:00
1 parent 293e6ddd33
commit 5630e7a848
72 files changed
+5760 -153

No files matched your search

+216
View File
@@ -0,0 +1,216 @@
// The zone page's DNSSEC tab: the zone's signing status, its DS record for
// the parent zone, and the steps to turn signing on and off. Every step
// that can affect resolvers opens a dialog that says what must be done
// first (docs/dnssec.md).
import { h, mount, table, badge, modal, toast, errorBox, display, input, select, checkbox, copyBox, fmtTime } from "../ui.js";
import { api, get, change, describeResult, state } from "../api.js";
import { rerender } from "../app.js";
import { publishDS } from "./registrar.js";
const phaseBadge = {
off: ["off", ""], leftover_keys: ["off · old keys", "warn"], starting: ["starting", "accent"],
signing: ["signing", "accent"], ready: ["ready for DS", "warn"], ds_pending: ["DS added", "accent"],
secure: ["secure", "ok"], removing_ds: ["turning off: remove DS", "warn"], ds_expiring: ["turning off", "accent"],
removing_keys: ["turning off", "accent"], unsigned: ["turning off: finished", "ok"],
};
export async function dnssecTab(el, zone) {
const [s, zones] = await Promise.all([get("/zones/" + zone.id + "/dnssec"), get("/zones").catch(() => [])]);
const admin = state.me && state.me.admin;
const others = zones.filter((z) => z.name === zone.name && z.id !== zone.id);
const ph = s.phase;
const [btext, bkind] = phaseBadge[ph.code] || [ph.code, ""];
const signing = s.policy && s.policy !== "insecure";
const out = h("div");
const buttons = [];
if (!s.policy) buttons.push(h("button", { class: "primary", onclick: () => enableDialog(zone, s, others) }, "Turn signing on…"));
if (s.policy) buttons.push(h("button", { onclick: () => checkDS(zone, out) }, "Check DS at parent"));
if (signing) buttons.push(h("button", { onclick: () => disableDialog(zone, s) }, "Turn signing off…"));
if (s.policy === "insecure") buttons.push(h("button", { class: ph.can_finish ? "primary" : "", disabled: !ph.can_finish, title: ph.can_finish ? "" : "Not yet: " + (ph.next || ph.summary), onclick: () => finishDialog(zone) }, "Finish turning off"));
if (admin && (s.policy || s.keys.length)) buttons.push(h("button", { class: "danger", onclick: () => wipeDialog(zone, s) }, "Wipe (clean slate)…"));
const parts = [h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, "DNSSEC signing ", badge(btext, bkind)),
h("div", { class: "actions" }, buttons)),
h("p", null, ph.summary),
ph.next ? h("div", { class: "msg " + (ph.code === "removing_ds" || ph.code === "ready" ? "warn" : "info") }, h("strong", null, "Next: "), ph.next) : null,
s.policy ? h("dl", { class: "kv small" },
h("dt", null, "Policy"), h("dd", { class: "mono" }, s.policy === "insecure" ? "insecure (turning off)" : s.policy),
s.named && s.named.secure ? [h("dt", null, "Signed serial"), h("dd", { class: "mono" }, s.named.signed_serial + " (zone file " + s.named.serial + ")")] : null,
s.named && s.named.next_key_event ? [h("dt", null, "Next key event"), h("dd", null, s.named.next_key_event)] : null,
h("dt", null, "Key directory"), h("dd", { class: "mono" }, s.key_dir)) : null,
s.named_error ? h("div", { class: "msg warn small" }, "named: " + s.named_error) : null,
s.key_error ? h("div", { class: "msg err small" }, "The keys can't be read: " + s.key_error) : null,
!s.policy && !s.keys.length ? h("p", { class: "muted small" },
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record in the parent zone (at your registrar), and resolvers on your own network don't validate zones this server answers itself.") : null,
out)];
const live = s.keys.filter((k) => k.ds && k.goal === "omnipresent");
if (live.length && s.policy !== "insecure") parts.push(dsPanel(zone, s, live));
if (s.keys.length) {
parts.push(h("div", { class: "panel" },
h("h2", null, "Keys"),
table([
{ label: "Tag", render: (k) => k.tag, tdClass: "mono" },
{ label: "Role", render: (k) => k.role },
{ label: "Algorithm", render: (k) => k.algorithm_name || k.algorithm, tdClass: "small" },
{ label: "Goal", render: (k) => stateBadge(k.goal) },
{ label: "DNSKEY", render: (k) => stateBadge(k.dnskey_state) },
{ label: "DS at parent", render: (k) => stateBadge(k.ds_state) },
{ label: "Created", render: (k) => k.created ? fmtTime(k.created) : "–", tdClass: "small" },
], s.keys),
h("p", { class: "muted small" }, "States are named's: rumoured = published, but caches may not have it yet; omnipresent = everyone has it; unretentive = removed, but caches may still have it; hidden = gone.")));
}
mount(el, parts);
}
function stateBadge(st) {
if (!st) return h("span", { class: "muted" }, "–");
return badge(st, { omnipresent: "ok", rumoured: "accent", unretentive: "warn" }[st] || "");
}
function dsPanel(zone, s, keys) {
const ph = s.phase;
const where = s.parent_zone
? ["The parent zone ", h("a", { href: "#/zones" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, display(zone.name)), ")."]
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
return h("div", { class: "panel" },
h("h2", null, "DS record for the parent zone"),
ph.can_publish_ds
? h("div", { class: "msg ok" }, ph.code === "secure" ? "The parent has this DS record." : "Ready: the DS record can be added at the parent now. Then click Check DS at parent.")
: h("div", { class: "msg warn" }, h("strong", null, "Not yet. "),
ph.ds_ready_at ? "Wait until " + fmtTime(ph.ds_ready_at) + "." : "Wait until named says the zone is ready.",
" Adding the DS earlier can make the zone fail to validate for resolvers that still have unsigned answers cached."),
h("p", { class: "small" }, where),
s.registrar && state.me && state.me.admin && ph.code !== "secure" && ph.code !== "ds_pending"
? h("p", null, h("button", { class: ph.can_publish_ds ? "primary" : "", onclick: () => publishDS(zone) }, "Publish at Porkbun…"),
h("span", { class: "muted small" }, " Jarl checks that it's safe first; the Registrar tab shows everything at Porkbun.")) : null,
keys.map((k) => h("div", { class: "ds-record" },
h("label", { class: "small muted" }, "Key " + k.tag + " — DS record"),
copyBox(k.ds.record.replace(/\t/g, " ")),
h("dl", { class: "kv small" },
h("dt", null, "Key tag"), h("dd", null, copyBox(String(k.ds.key_tag))),
h("dt", null, "Algorithm"), h("dd", null, copyBox(String(k.ds.algorithm)), h("span", { class: "muted" }, " " + (k.algorithm_name || ""))),
h("dt", null, "Digest type"), h("dd", null, copyBox(String(k.ds.digest_type)), h("span", { class: "muted" }, " SHA-256")),
h("dt", null, "Digest"), h("dd", null, copyBox(k.ds.digest))))));
}
async function apply(zone, policy) {
const r = await change("PATCH", "/zones/" + zone.id, { dnssec_policy: policy }, { immediate: true });
toast(describeResult(r));
rerender();
}
function steps(items) {
return h("ol", { class: "small" }, items.map((i) => h("li", null, i)));
}
async function enableDialog(zone, s, others) {
const pol = select(s.policies.map((p) => [p, p === "default" ? "default (BIND's built-in policy)" : p]), "default");
const err = h("div");
const split = others.length ? h("div", { class: "msg warn small" },
"This signs the zone in view ", h("strong", null, zone.view || "default"), " only; ",
others.map((o) => display(o.name) + " in view " + o.view).join(", "),
" stays unsigned. That's the usual split-DNS setup. Once the DS record is at the parent, a device that validates DNSSEC itself (a Pi-hole or Unbound with DNSSEC on, systemd-resolved with DNSSEC=yes) and gets the unsigned copy rejects it; resolvers asking this server directly are fine, because BIND doesn't validate zones it serves itself.") : null;
await modal("Turn signing on for " + zone.display_name, [
h("p", null, "named creates a key and signs the zone: Jarl keeps writing the zone as before, and named keeps a signed copy next to it. ",
h("strong", null, "Nothing changes for resolvers yet"), ": they only start validating once the zone's DS record is added at the parent zone, which this page tells you when to do."),
h("p", { class: "small" }, "Before you start:"),
steps([
"The server's clock must be right (NTP): signatures are valid for a fixed time window.",
"TTLs in the zone must be at most a day; Jarl refuses longer ones.",
"Secondaries get the signed zone by zone transfer; they need nothing else.",
"With the default policy the key is never replaced, so the DS at your registrar never has to change. Keep backups: the daily backup includes the keys.",
]),
split,
s.policies.length > 1 ? h("div", { class: "form-grid" }, h("label", null, "Policy"), pol) : null,
err,
], [
{ label: "Cancel", value: null },
{ label: "Turn signing on", kind: "primary", onclick: async () => {
try { await apply(zone, pol.value); } catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true });
}
async function disableDialog(zone, s) {
const err = h("div");
const hadDS = s.keys.some((k) => k.ds_published || k.ds_state === "omnipresent");
await modal("Turn signing off for " + zone.display_name, [
h("p", null, "Signing is turned off in steps, so the zone is never unsigned while the parent zone still has its DS record (validating resolvers would then reject the whole domain):"),
steps([
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
[h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
]),
hadDS ? null : h("p", { class: "muted small" }, "named has never seen a DS for this zone at its parent, so this will go quickly. If you're sure the parent never had one, Wipe switches off at once."),
err,
], [
{ label: "Cancel", value: null },
{ label: "Start turning off", kind: "primary", onclick: async () => {
try { await apply(zone, "insecure"); } catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true });
}
async function finishDialog(zone) {
const err = h("div");
await modal("Finish turning signing off", [
h("p", null, "The parent zone has no DS record for ", h("strong", null, zone.display_name), " any more, and named has stopped counting on it. Switching DNSSEC off now is safe: named serves the zone unsigned."),
h("p", { class: "muted small" }, "The old keys stay in the key directory (named keeps them retired); Wipe deletes them."),
err,
], [
{ label: "Cancel", value: null },
{ label: "Switch DNSSEC off", kind: "primary", onclick: async () => {
try { await apply(zone, ""); } catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
}
async function wipeDialog(zone, s) {
const err = h("div");
const confirmName = input({ placeholder: zone.display_name, class: "mono", autocomplete: "off" });
const force = checkbox("The parent can't be checked from here, and I'm sure it has no DS record for this zone (for example, the zone isn't on the internet)", false);
await modal("Wipe DNSSEC from " + zone.display_name, [
h("div", { class: "msg err" },
h("strong", null, "This can take the domain offline. "),
"Wipe switches signing off at once and deletes the zone's keys and named's signed copy. If the parent zone still has a DS record for the zone, or resolvers still have one cached, every validating resolver rejects the whole domain until it's gone and its TTL has passed (often a day). The deleted keys can't be brought back except from a backup."),
h("p", null, "Jarl asks the parent zone's servers first and refuses while they have a DS record. Use Turn signing off instead if the zone is on the internet and ever had a DS."),
s.policy ? null : h("p", { class: "small" }, "Signing is already off; this deletes the old keys left in the key directory."),
h("div", { class: "form-grid" }, h("label", null, "Type the zone name to confirm"), confirmName),
force,
err,
], [
{ label: "Cancel", value: null },
{ label: "Wipe", kind: "danger", onclick: async () => {
if (confirmName.value.trim().replace(/\.$/, "").toLowerCase() !== zone.display_name.toLowerCase()) {
mount(err, h("div", { class: "msg err" }, "Type the zone name to confirm."));
return false;
}
try {
const r = await api("POST", "/zones/" + zone.id + "/dnssec/wipe", { body: { force: force.input.checked } });
toast("Wiped: " + r.removed.length + " files removed");
rerender();
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true, sticky: true });
}
async function checkDS(zone, out) {
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
try {
const r = await api("POST", "/zones/" + zone.id + "/dnssec/check-ds");
const p = r.parent;
mount(out, h("div", { class: "msg " + (p.error ? "err" : "info") },
p.error ? p.error : [
"Parent zone ", h("strong", { class: "mono" }, display(p.parent)), " (" + p.reached + " of " + p.parent_servers.length + " servers answered): ",
p.delegated ? (p.ds.length ? "DS record" + (p.ds.length > 1 ? "s" : "") + " for key " + p.ds.map((d) => d.key_tag).join(", ") : "no DS record") : "the zone isn't delegated",
"."],
r.marked.map((m) => h("div", null, "named told: " + m)),
r.notes.map((n) => h("div", { class: "small" }, n))));
if (r.marked.length) setTimeout(rerender, 1500);
} catch (e) { mount(out, errorBox(e)); }
}