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>
217 lines
14 KiB
JavaScript
217 lines
14 KiB
JavaScript
// 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)); }
|
||
}
|