import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList } 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] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]); 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.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) : "", 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 : "" })); }; 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)); 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); } } export async function newZoneDialog(views, zones, settings) { 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])], 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 comment = input(); 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") + "]"))); }; // 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..")); 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); 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", cloneSel, "For split-horizon setups: start with the records of this zone in another view.")], 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 drawType = () => { 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), 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") { 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) { 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)); 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]; } export function pendingBadge(kind) { return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete" }[kind] || kind, kind === "delete" ? "err" : "warn"); }