Suggestions split into networks that are yours (private IPv4 /24, ULA /48, an IPv6 /48 this server has an address in), which Create all creates, and public addresses (/32 or /128) to create only if they are yours or dismiss. A view can turn suggestions off, which also keeps it out of choosing a reverse zone's view (database version 13). A zone created inside another in the same view takes the parent's records under it; a delegation and its glue stay, a linked parent keeps them with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
21 KiB
JavaScript
322 lines
21 KiB
JavaScript
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
|
||
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
||
import { rerender } from "../app.js";
|
||
import { checkDomain, reverseZoneFor } from "../names.js";
|
||
|
||
export async function render(el, params, query) {
|
||
const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"),
|
||
get("/reverse-zones/suggestions", draftQuery()).catch(() => [])]);
|
||
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
||
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
||
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
||
const viewsById = new Map(views.map((v) => [v.id, v]));
|
||
const list = h("div");
|
||
const draw = () => {
|
||
const q = filter.value.trim().toLowerCase();
|
||
// Keep the filters in the address, so going back to the list (or a
|
||
// breadcrumb) restores them.
|
||
const qs = new URLSearchParams();
|
||
if (filter.value.trim()) qs.set("q", filter.value.trim());
|
||
if (viewSel.value) qs.set("view", viewSel.value);
|
||
if (typeSel.value) qs.set("type", typeSel.value);
|
||
const hash = "#/zones" + (qs.toString() ? "?" + qs : "");
|
||
if (location.hash !== hash && !query.get("new")) history.replaceState(null, "", hash);
|
||
lastList.zones = hash;
|
||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||
mount(list, table([
|
||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.prefix ? h("span", { class: "muted small" }, " " + z.prefix) : null, z.ptr_missing ? [" ", badge(z.ptr_missing + " PTR missing", "warn")] : null, dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
|
||
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
||
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
||
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => [z.pending ? "pending-" + z.pending : "", z.disabled ? "disabled" : ""].filter(Boolean).join(" ") }));
|
||
};
|
||
filter.addEventListener("input", draw);
|
||
viewSel.addEventListener("change", draw);
|
||
typeSel.addEventListener("change", draw);
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
|
||
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
|
||
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
|
||
h("div", { class: "panel" }, list),
|
||
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
|
||
draw();
|
||
if (query.get("new")) {
|
||
// Drop ?new=1 so re-rendering after the zone is created doesn't open
|
||
// the dialog again.
|
||
history.replaceState(null, "", "#/zones");
|
||
newZoneDialog(views, zones, settings);
|
||
}
|
||
}
|
||
|
||
// preset.view picks the view (opened from a view's page).
|
||
export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||
const err = h("div");
|
||
// The zone's name matters most and is easy to get wrong, so the dialog
|
||
// asks for a domain or a network and shows the resulting zone name.
|
||
let kind = "forward";
|
||
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
|
||
const nameLabel = h("label", { for: "zone-name" }, "Domain");
|
||
name.id = "zone-name";
|
||
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
||
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
||
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
||
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
|
||
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
|
||
const nsAddr = textarea("", { class: "mono", rows: 2, placeholder: "ns1 192.0.2.1" });
|
||
const contact = input({ value: settings.rname ? emailOf(settings.rname) : "", placeholder: "hostmaster@example.com" });
|
||
const ttl = input({ type: "number", value: settings.default_ttl || 3600, min: 0 });
|
||
const primaries = textarea("", { class: "mono", rows: 2, placeholder: "192.0.2.53\n2001:db8::53 key xfer-key" });
|
||
const forwarders = textarea("", { class: "mono", rows: 2, placeholder: "192.168.1.1\n127.0.0.1 port 8600" });
|
||
const forwardMode = select(["only", "first"], "only");
|
||
const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
|
||
const inView = select(views.map((v) => [v.name, v.name]), "");
|
||
const cloneSel = select([["", "– don't copy –"]], "");
|
||
const linkBox = checkbox("Keep them linked: shared records stay in step, each view can add its own", true);
|
||
const cloneWrap = h("div", null, cloneSel, linkBox);
|
||
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
|
||
cloneSel.addEventListener("change", syncLink);
|
||
const comment = input();
|
||
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
|
||
const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
|
||
const updateClones = () => {
|
||
// Records can only be copied into a single zone.
|
||
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
||
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
|
||
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
|
||
syncLink();
|
||
};
|
||
// check validates the name as typed and returns the zone name (or null);
|
||
// a network that needs several reverse zones gives a list of names.
|
||
const check = () => {
|
||
const v = name.value.trim();
|
||
const msgs = [];
|
||
let zoneName = null;
|
||
if (type.value === "hint" && (v === "." || v === "")) {
|
||
mount(preview, h("div", null, "Zone name: ", h("strong", { class: "mono" }, "."), " (the root, for root hints)"));
|
||
return ".";
|
||
}
|
||
if (!v) { mount(preview, h("div", { class: "muted" }, kind === "reverse" ? "The zone name is generated from the network." : "Records in the zone are named relative to it: www becomes www.<zone>.")); return null; }
|
||
if (kind === "reverse") {
|
||
const r = reverseZoneFor(v);
|
||
if (r.error) msgs.push(["err", r.error]);
|
||
else zoneName = r.name || r.names;
|
||
const single = /\/(32|128)$/.test(r.network || "");
|
||
const what = single ? " · answers \"which host has address " + r.network.replace(/\/\d+$/, "") + "?\" and nothing else in its network"
|
||
: " · answers \"which host has address …?\" for " + r.network;
|
||
mount(preview,
|
||
typeof zoneName === "string" ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, what)) : null,
|
||
Array.isArray(zoneName) ? h("div", null,
|
||
h("div", null, "Reverse names follow the dots, so " + r.network + " is covered by " + zoneName.length + " zones, created together:"),
|
||
h("div", { class: "mono small" }, zoneName.join(", ")),
|
||
h("div", { class: "muted small" }, "Answers \"which host has address …?\" for " + r.network + ".")) : null,
|
||
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
|
||
} else {
|
||
const c = checkDomain(v);
|
||
c.problems.forEach((m) => msgs.push(["err", m]));
|
||
c.notes.forEach((m) => msgs.push(["warn", m]));
|
||
if (!c.problems.length) zoneName = c.name;
|
||
}
|
||
if (zoneName && kind === "forward") {
|
||
const fq = zoneName + ".";
|
||
const inView = (z) => (z.view || "") === (view.value || "");
|
||
const same = zones.find((z) => z.name === fq && inView(z));
|
||
if (same) { msgs.unshift(["err", "zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "") + ": open it from the zone list"]); zoneName = null; }
|
||
else {
|
||
const parent = zones.filter((z) => inView(z) && z.name !== "." && fq.endsWith("." + z.name)).sort((a, b) => b.name.length - a.name.length)[0];
|
||
if (parent) msgs.push(["info", "a child of zone " + parent.display_name + ": add NS records for " + fq.replace(/\.$/, "") + " there so other servers can find it"]);
|
||
const kids = zones.filter((z) => inView(z) && z.name.endsWith("." + fq));
|
||
if (kids.length) msgs.push(["info", "contains the existing zone" + (kids.length > 1 ? "s " : " ") + kids.map((z) => z.display_name).join(", ")]);
|
||
}
|
||
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · a record named www becomes www." + zoneName)) : null,
|
||
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
|
||
}
|
||
if (zoneName && kind === "reverse") {
|
||
const existing = [zoneName].flat().filter((n) => zones.find((z) => z.name === n + "." && (z.view || "") === (view.value || "")));
|
||
if (existing.length) {
|
||
mount(preview, h("div", { class: "msg err small" }, "the reverse zone" + (existing.length > 1 ? "s " : " ") + existing.join(", ") + (existing.length > 1 ? " already exist" : " already exists") + (view.value ? " in view " + view.value : "")));
|
||
return null;
|
||
}
|
||
}
|
||
return zoneName;
|
||
};
|
||
const setKind = (k) => {
|
||
kind = k;
|
||
mount(err);
|
||
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
|
||
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
|
||
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
|
||
syncFill();
|
||
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
|
||
check();
|
||
name.focus();
|
||
};
|
||
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
|
||
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
|
||
}
|
||
name.addEventListener("input", () => { mount(err); updateClones(); check(); });
|
||
view.addEventListener("change", check);
|
||
const rows = {
|
||
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
||
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
||
field("SOA contact", contact, "Email address of the zone's administrator."),
|
||
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
||
field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).")],
|
||
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
||
stub: [field("Primary servers", primaries, "One per line.")],
|
||
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
||
forward: [field("Forwarders", forwarders, "One per line: address [port N]. Leave empty to disable forwarding for this domain."), field("Forward", forwardMode, "only: never resolve these names directly; first: try the forwarders, then resolve normally.")],
|
||
"static-stub": [field("Server addresses", primaries, "One per line.")],
|
||
hint: [field("File", file, "Root hints file, e.g. /usr/share/dns/root.hints.")],
|
||
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
|
||
};
|
||
const typeRows = h("div", { class: "form-grid" });
|
||
const syncFill = () => { for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none"; };
|
||
const drawType = () => {
|
||
syncFill();
|
||
mount(typeRows, rows[type.value] || []);
|
||
if (type.value === "hint" && !name.value.trim()) name.value = ".";
|
||
check();
|
||
};
|
||
type.addEventListener("change", drawType);
|
||
drawType();
|
||
const body = h("div", null,
|
||
h("div", { class: "form-grid" },
|
||
h("label", null, "Kind"), h("div", null, kindButtons),
|
||
nameLabel, h("div", null, name, preview),
|
||
views.length ? field("View", view) : null,
|
||
field("Type", type),
|
||
fillRow,
|
||
field("Comment", comment)),
|
||
h("div", { style: { marginTop: "8px" } }, typeRows),
|
||
err);
|
||
await modal("New zone", body, [
|
||
{ label: "Cancel", value: null },
|
||
{ label: "Create zone", kind: "primary", onclick: async () => {
|
||
const t = type.value;
|
||
const zoneName = check();
|
||
if (!zoneName) {
|
||
mount(err, h("div", { class: "msg err" }, name.value.trim() ? "Fix the " + (kind === "reverse" ? "network" : "zone name") + " first." : "Give the " + (kind === "reverse" ? "network" : "domain") + " first."));
|
||
name.focus();
|
||
return false;
|
||
}
|
||
const data = { name: zoneName, type: t, comment: comment.value || undefined };
|
||
if (view.value) data.view = view.value;
|
||
const opts = [];
|
||
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
||
if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true;
|
||
if (t === "primary") {
|
||
data.nameservers = lines(ns.value);
|
||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||
data.default_ttl = Number(ttl.value) || undefined;
|
||
if (cloneSel.value) {
|
||
const src = zones.find((z) => z.id === Number(cloneSel.value));
|
||
if (linkBox.input.checked && src && src.view) data.link_with = src.view;
|
||
else data.clone_from = Number(cloneSel.value);
|
||
delete data.nameservers;
|
||
}
|
||
data.records = lines(nsAddr.value).map((l) => {
|
||
const [n, a] = l.split(/\s+/);
|
||
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };
|
||
});
|
||
} else if (t === "secondary" || t === "stub" || t === "mirror") {
|
||
if (lines(primaries.value).length) opts.push({ name: "primaries", value: "{ " + list(primaries) + " }" });
|
||
} else if (t === "static-stub") {
|
||
opts.push({ name: "server-addresses", value: "{ " + list(primaries) + " }" });
|
||
} else if (t === "forward") {
|
||
opts.push({ name: "forwarders", value: "{ " + list(forwarders) + " }" });
|
||
opts.push({ name: "forward", value: forwardMode.value });
|
||
} else if (t === "hint") {
|
||
data.file = file.value.trim();
|
||
} else if (t === "in-view") {
|
||
data.in_view = inView.value;
|
||
}
|
||
if (opts.length) data.options = opts;
|
||
try {
|
||
const r = Array.isArray(zoneName)
|
||
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
|
||
: await change("POST", "/zones", data);
|
||
toast(describeResult(r));
|
||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||
rerender();
|
||
return true;
|
||
} catch (e) {
|
||
mount(err, errorBox(e));
|
||
return false;
|
||
}
|
||
} },
|
||
], { wide: true, sticky: true });
|
||
}
|
||
|
||
export function emailOf(rname) {
|
||
if (!rname) return "";
|
||
const s = rname.replace(/\.$/, "");
|
||
const m = s.match(/^((?:[^.\\]|\\.)+)\.(.+)$/);
|
||
if (!m) return s;
|
||
return m[1].replace(/\\\./g, ".") + "@" + m[2];
|
||
}
|
||
|
||
// dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab:
|
||
// closed while signed, open while signing is being turned off (BIND's
|
||
// insecure policy).
|
||
export function dnssecLock(z) {
|
||
if (!z.dnssec_policy) return null;
|
||
const off = z.dnssec_policy === "insecure";
|
||
const a = h("a", {
|
||
class: "dnssec-lock" + (off ? " off" : ""),
|
||
href: "#/zones/" + z.id + "/dnssec",
|
||
title: off ? "DNSSEC: signing is being turned off" : "DNSSEC: signed (policy " + z.dnssec_policy + ")",
|
||
"aria-label": off ? "DNSSEC being turned off" : "DNSSEC signed",
|
||
});
|
||
a.innerHTML = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="' +
|
||
(off ? "M5 7V5a3 3 0 0 1 5.8-1.1" : "M5 7V5a3 3 0 0 1 6 0v2") +
|
||
'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="7" width="10" height="7.5" rx="1.5" fill="currentColor"/></svg>';
|
||
return a;
|
||
}
|
||
|
||
export function pendingBadge(kind) {
|
||
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
||
}
|
||
|
||
// suggestPanel lists the reverse zones the private views lack for their
|
||
// records' addresses, to create filled with PTR records (docs/reverse.md):
|
||
// networks that are yours, and public addresses, which may be a web
|
||
// host's, to create one by one or dismiss.
|
||
function suggestPanel(sugg, views) {
|
||
const create = async (list) => {
|
||
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
|
||
try {
|
||
const r = reqs.length === 1 ? await change("POST", "/zones", reqs[0].data) : await changeMany(reqs, plural(reqs.length, "reverse zone"));
|
||
toast(describeResult(r));
|
||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
};
|
||
const dismiss = async (s) => {
|
||
try {
|
||
const r = await change("POST", "/reverse-zones/dismiss", { view_id: s.view_id || undefined, prefix: s.prefix });
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
};
|
||
const cols = (pub) => [
|
||
views.length ? { label: "View", render: (s) => s.view } : null,
|
||
{ label: pub ? "Address" : "Network", render: (s) => [h("span", { class: "mono" }, pub ? s.prefix.replace(/\/(32|128)$/, "") : s.prefix), pub ? null : h("div", { class: "muted small" }, s.why)] },
|
||
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
|
||
{ label: pub ? "Name" : "PTR records", render: (s) => pub ? h("span", { class: "mono small" }, s.samples.map((x) => x.split(" ")[1]).join(", ")) : [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
|
||
{ label: "", render: (s) => [h("button", { class: "small", onclick: () => create([s]) }, "Create"), pub ? [" ", h("button", { class: "small", title: "Not yours (a web host's, say): don't suggest it again", onclick: () => dismiss(s) }, "Dismiss")] : null], tdClass: "actions-cell" },
|
||
].filter(Boolean);
|
||
const yours = sugg.filter((s) => s.group === "yours");
|
||
const pub = sugg.filter((s) => s.group !== "yours");
|
||
return h("div", { class: "panel" },
|
||
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
|
||
yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null),
|
||
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
|
||
yours.length ? table(cols(false), yours) : null,
|
||
pub.length ? [h("h3", null, "Public addresses: only if they're yours"),
|
||
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's (a web host's). Create a zone for an address that is yours; dismiss the others. Each gets a zone of its own: the rest of its network belongs to others."),
|
||
table(cols(true), pub)] : null);
|
||
}
|