Web UI: zone naming and reverse zones, ACL suggestions, record form

- New zone dialog: choose Domain or Reverse zone. Reverse zones are
  created from a network and their name is generated; both kinds show the
  exact zone name and flag likely mistakes (addresses or URLs as names,
  host-like names, duplicates in the view, parent/child zones, networks
  off the octet/nibble boundaries) before anything is submitted.
- Record form: clears every field but the type after an add; shows the
  record exactly as it will be stored and warns about unqualified names.
  In reverse zones it takes an address, defaults to PTR, suggests host
  names of A/AAAA records with that address and warns about a second PTR;
  the record list shows addresses.
- Address match lists (options, view match lists, ACL elements) offer the
  existing ACLs, TSIG keys and built-ins as one-click suggestions and
  flag unknown names.
- Server: in reverse zones a record can be named by its address, and a
  PTR target like nas.example.com without the final dot is read as the
  full host name (a bare label is refused). Warnings noticed while parsing
  record data reach the result again.
- Fixes: brand link hover underline, the dashboard's "New zone" link
  opening the dialog twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 20:49:43 +00:00
1 parent 3b7bee67f8
commit bf7f99874b
19 files changed
+577 -35

No files matched your search

+89 -7
View File
@@ -1,6 +1,7 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
import { get, change, 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")]);
@@ -30,12 +31,24 @@ export async function render(el, params, query) {
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")) newZoneDialog(views, zones, settings);
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");
const name = input({ placeholder: "example.com or 192.168.1.0/24" });
// 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." });
@@ -50,11 +63,69 @@ export async function newZoneDialog(views, zones, settings) {
const cloneSel = select([["", "– don't copy –"]], "");
const comment = input();
const updateClones = () => {
const n = name.value.trim().toLowerCase().replace(/\.$/, "") + ".";
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") + "]")));
};
name.addEventListener("input", updateClones);
// check validates the name as typed and returns the zone name (or null).
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;
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · 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 fq = zoneName + ".";
if (zones.find((z) => z.name === fq && (z.view || "") === (view.value || ""))) {
mount(preview, h("div", { class: "msg err small" }, "the reverse zone " + zoneName + " 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)."),
@@ -70,12 +141,17 @@ export async function newZoneDialog(views, zones, settings) {
"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] || []);
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" },
field("Name", name, "A domain name, or a network such as 192.168.1.0/24 to create its reverse zone."),
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)),
@@ -85,7 +161,13 @@ export async function newZoneDialog(views, zones, settings) {
{ label: "Cancel", value: null },
{ label: "Create zone", kind: "primary", onclick: async () => {
const t = type.value;
const data = { name: name.value.trim(), type: t, comment: comment.value || undefined };
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(" ");