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:
1 parent
3b7bee67f8
commit
bf7f99874b
19 files changed
+577
-35
No files matched your search
@@ -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(" ");
|
||||
|
||||
Reference in new issue
Block a user