// 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)); } }