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)); }
}
@@ -0,0 +1,128 @@
// 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;
}
+7
View File
@@ -4,6 +4,8 @@ import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js";
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
import { dnssecTab } from "./dnssec.js";
import { registrarTab } from "./registrar.js";
export async function render(el, params, query) {
const id = params.id;
@@ -15,6 +17,8 @@ export async function render(el, params, query) {
["options", "Options"],
zone.type === "primary" ? ["file", "Zone file"] : null,
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
].filter(Boolean);
const body = h("div");
mount(el,
@@ -26,6 +30,7 @@ export async function render(el, params, query) {
zone.pending ? [pendingBadge(zone.pending), " "] : null,
zone.view ? ["view ", h("a", { href: "#/views" }, zone.view), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" },
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))),
@@ -36,6 +41,8 @@ export async function render(el, params, query) {
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
else if (tab === "file") await fileTab(body, zone);
else if (tab === "servers") await serversTab(body, zone);
else if (tab === "dnssec") await dnssecTab(body, zone);
else if (tab === "registrar") await registrarTab(body, zone);
}
// ---- name servers: serials on the other servers, and the delegation
+7
View File
@@ -243,6 +243,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.grants { font-family: var(--mono); font-size: 12px; }
.copy-box { display: flex; gap: 6px; align-items: center; }
.copy-box input { flex: 1; font-family: var(--mono); }
.ds-record { margin: 12px 0 16px; }
.ds-record > .copy-box { max-width: 900px; }
.ds-record .kv { margin-top: 8px; align-items: center; grid-template-columns: max-content minmax(0, 1fr); }
.ds-record .kv dd { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
.ds-record .kv dd .copy-box { flex: 1 1 200px; max-width: 640px; min-width: 0; }
.copy-box input { min-width: 0; }
td.break { word-break: break-all; }
.sortable-row { display: flex; align-items: center; gap: 8px; }
@media (max-width: 700px) {
.form-grid { grid-template-columns: 1fr; }