Files
jarl/internal/web/static/assets/pages/registrar.js
T
abrown38andClaude Opus 5.5 5630e7a848 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>
2026-10-08 21:19:04 +00:00

129 lines
8.5 KiB
JavaScript

// The zone page's Registrar tab: the domain's name servers, glue records
// and DS records at Porkbun, next to what the zone says. Every change is
// first run as a dry run, whose checks (will this break the domain?) the
// dialog shows before anything is changed (docs/dnssec.md#porkbun).
import { h, mount, table, badge, modal, toast, errorBox, checkbox } from "../ui.js";
import { api, get } from "../api.js";
import { rerender } from "../app.js";
const lvl = { ok: "ok", info: "info", warning: "warn", error: "err" };
export async function registrarTab(el, zone) {
const r = await get("/zones/" + zone.id + "/registrar");
if (r.error) {
mount(el, h("div", { class: "panel" },
h("h2", null, "Registrar: Porkbun"),
h("div", { class: "msg err" }, r.error),
h("p", { class: "muted small" }, "Check the API key in jarl.yaml (porkbun:), that \"API Access\" is switched on for the domain at porkbun.com, and that the key may manage it.")));
return;
}
const z = "/zones/" + zone.id + "/registrar";
const toDo = r.glue.filter((g) => g.action).length;
mount(el,
h("div", { class: "panel" },
h("h2", null, "Registrar: Porkbun ", h("span", { class: "muted small" }, r.domain)),
r.findings.map((f) => h("div", { class: "msg " + lvl[f.level] }, f.message))),
h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, "Name servers ", r.ns_match ? badge("match the zone", "ok") : badge("differ from the zone", "warn")),
h("div", { class: "actions" }, h("button", { disabled: r.ns_match, onclick: () => guarded({
title: "Delegate " + r.domain + " to the zone's name servers",
intro: [
h("p", null, "Porkbun will point the whole domain at ", h("strong", { class: "mono" }, r.jarl_ns.join(", ")), " instead of ", h("span", { class: "mono" }, r.ns.join(", ")), "."),
h("p", { class: "small" }, "Jarl checks first that every one of them answers for the zone with the current serial, that the ones inside the domain have matching glue records, and that any DS record at Porkbun matches the zone's keys. Resolvers may keep using the old name servers for a day or two, so keep those serving the zone until then."),
],
method: "PUT", path: z + "/ns", ok: "Set name servers",
}) }, "Set name servers…"))),
table([
{ label: "", render: (row) => h("strong", null, row[0]) },
{ label: "Name servers", render: (row) => row[1].join(", ") || h("span", { class: "muted" }, "none"), tdClass: "mono" },
], [["At Porkbun", r.ns], ["In the zone (NS records)", r.jarl_ns]])),
h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, "Glue records"),
h("div", { class: "actions" }, h("button", { disabled: !toDo, onclick: () => guarded({
title: "Sync the glue records",
intro: h("p", null, "Name servers inside the domain (like ns1." + r.domain + ") can only be found through glue records at the registry. Each one gets all of its A and AAAA records from the zone. Changing the glue of a name server the domain already uses sends resolvers to the new addresses at once, so Jarl checks they answer for the zone first."),
method: "POST", path: z + "/glue", ok: "Sync glue",
}) }, "Sync glue…"))),
r.glue.length ? table([
{ label: "Name server", render: (g) => g.host, tdClass: "mono" },
{ label: "At Porkbun", render: (g) => g.registry.join(", ") || h("span", { class: "muted" }, "none"), tdClass: "mono small" },
{ label: "In the zone", render: (g) => g.needed ? (g.jarl.join(", ") || h("span", { class: "muted" }, "no address record")) : h("span", { class: "muted" }, "not a name server of the zone"), tdClass: "mono small" },
{ label: "", render: (g) => g.action ? badge(g.action === "create" ? "missing" : "differs", "warn") : g.needed ? badge("matches", "ok") : g.in_use ? badge("in use", "") : badge("unused", "") },
], r.glue) : h("p", { class: "muted small" }, "None needed: no name server of the zone is inside the domain.")),
h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, "DNSSEC: DS records at Porkbun"),
h("div", { class: "actions" }, h("button", { onclick: () => publishDS(zone) }, "Publish the zone's DS…"))),
r.ds.length ? table([
{ label: "Key tag", render: (d) => d.key_tag, tdClass: "mono" },
{ label: "Algorithm", render: (d) => d.algorithm },
{ label: "Digest type", render: (d) => d.digest_type },
{ label: "Digest", render: (d) => d.digest, tdClass: "mono small break" },
{ label: "", render: (d) => d.matches ? badge("matches the zone's key", "ok") : badge("matches no key", "err") },
{ label: "", render: (d) => h("button", { class: "small danger", onclick: () => guarded({
title: "Remove the DS record for key " + d.key_tag,
intro: [
d.matches
? h("div", { class: "msg warn" }, "This DS record points at the zone's key. Without it, resolvers stop validating the domain; the zone stays signed. This is the right step after starting to turn signing off (DNSSEC tab); otherwise, turn signing off first.")
: h("div", { class: "msg info" }, "This DS record matches none of the zone's keys, so validating resolvers reject the domain while it's there: removing it fixes that."),
h("p", { class: "small muted" }, "Resolvers may keep it cached for its TTL (often a day). Porkbun notes that most registries remove every DS record with the same key data."),
],
method: "DELETE", path: z + "/ds/" + d.key_tag, ok: "Remove DS record", kind: "danger",
}) }, "Remove…") },
], r.ds) : h("p", { class: "muted small" }, "None: resolvers treat the domain as unsigned.")),
);
}
// publishDS publishes the zone's DS record at Porkbun (from the DNSSEC tab
// too).
export function publishDS(zone) {
return guarded({
title: "Publish the DS record at Porkbun",
intro: [
h("p", null, "Porkbun adds the zone's DS record to the parent zone, and from then on validating resolvers check the zone's signatures."),
h("p", { class: "small" }, "Jarl checks first that named says the zone is ready, and that the domain is delegated to name servers that serve this signed zone: a DS record that points at servers serving it unsigned (or with other keys) takes the domain offline for validating resolvers."),
h("p", { class: "small muted" }, "Afterwards, once the parent zone has it (minutes to hours), click Check DS at parent on the DNSSEC tab."),
],
method: "POST", path: "/zones/" + zone.id + "/registrar/ds", ok: "Publish DS record",
});
}
// guarded runs a registrar change as a dry run, shows its checks, and
// makes the change when confirmed. Errors block it unless the user
// explicitly overrides them.
async function guarded({ title, intro, method, path, ok, kind = "primary" }) {
const body = h("div", null, h("div", { class: "muted" }, "Checking…"));
const out = h("div");
let blocked = false;
const override = checkbox("I understand the checks above and want to make this change anyway", false);
const done = modal(title, [intro, body, out], [
{ label: "Cancel", value: null },
{ label: ok, kind, onclick: async () => {
if (blocked && !override.input.checked) {
mount(out, h("div", { class: "msg err" }, "The checks found problems: tick the box to go ahead anyway."));
return false;
}
try {
const r = await api(method, path, { body: method === "DELETE" ? undefined : { force: blocked } , query: method === "DELETE" && blocked ? { force: "1" } : undefined });
toast(r.done.length ? r.done.join("; ") : "Nothing to change", "ok", 6000);
for (const w of r.warnings) toast(w, "ok", 9000);
rerender();
} catch (e) { mount(out, errorBox(e)); return false; }
} },
], { wide: true, sticky: true });
try {
const r = await api(method, path, { body: method === "DELETE" ? undefined : { dry_run: true }, query: method === "DELETE" ? { dry_run: "1" } : undefined });
blocked = r.checks.some((c) => c.level === "error");
mount(body,
r.checks.map((c) => h("div", { class: "msg " + lvl[c.level] }, c.message)),
r.done.length ? [h("p", { class: "small" }, "This will:"), h("ul", { class: "small" }, r.done.map((d) => h("li", null, d)))] : h("p", { class: "muted" }, "Nothing to change."),
blocked ? override : null);
} catch (e) { mount(body, errorBox(e)); }
return done;
}