Files
adamandClaude Opus 5.5 113a6913a2 Give the DS command for a parent zone in Jarl, and allow port 443
The DNSSEC tab and 'jarl zone dnssec status' give the jarl record add
command (and an Add to parent button) when the parent zone is in Jarl.
jarl.service grants CAP_NET_BIND_SERVICE so the web UI can listen on 443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:42:16 +00:00

250 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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] || "");
}
// parentName is the zone's name relative to its parent zone in Jarl
// ("sectest" for sectest.adbraun.lol in adbraun.lol).
function parentName(zone, s) {
return zone.name.slice(0, -(s.parent_zone.length + 1));
}
// jarlCmd is the jarl command for a record change in the parent zone.
function jarlCmd(zone, s, verb, data) {
return ["jarl record", verb, display(s.parent_zone), parentName(zone, s), "DS", data, s.parent_view ? "--view " + s.parent_view : ""].filter(Boolean).join(" ");
}
const dsData = (k) => [k.ds.key_tag, k.ds.algorithm, k.ds.digest_type, k.ds.digest].join(" ");
// inParent reports whether the parent zone in Jarl has the key's DS record.
const inParent = (s, k) => (s.parent_ds || []).some((d) => d.toUpperCase() === dsData(k).toUpperCase());
async function addToParent(zone, s, k) {
try {
const r = await change("POST", "/zones/" + s.parent_zone_id + "/records", { name: parentName(zone, s), type: "DS", data: dsData(k) });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
function dsPanel(zone, s, keys) {
const ph = s.phase;
const where = s.parent_zone
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
: "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, " ")),
s.parent_zone ? [
h("label", { class: "small muted" }, "jarl command"),
copyBox(jarlCmd(zone, s, "add", dsData(k))),
inParent(s, k)
? h("p", { class: "small" }, badge("in " + display(s.parent_zone), "ok"), " ", display(s.parent_zone) + " has this DS record.")
: h("p", null, h("button", { class: ph.can_publish_ds ? "primary" : "", disabled: !ph.can_publish_ds, title: ph.can_publish_ds ? "" : "Not yet: see above", onclick: () => addToParent(zone, s, k) }, "Add to " + display(s.parent_zone))),
] : null,
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."],
s.parent_zone
? [h("strong", null, "You: "), "remove the DS record from ", display(s.parent_zone), " (", h("code", null, jarlCmd(zone, s, "delete", "")), ", or on its Records page), then click Check DS at parent so named knows it's gone."]
: [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)); }
}