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
@@ -3,6 +3,7 @@ import { api, get, change, describeResult, rrtypes, state, draftQuery } from "..
|
||||
import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
import { emailOf, pendingBadge } from "./zones.js";
|
||||
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const id = params.id;
|
||||
@@ -53,26 +54,104 @@ async function recordsTab(el, zone, query) {
|
||||
const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]);
|
||||
const typeInfo = Object.fromEntries(types.map((t) => [t.type, t]));
|
||||
const err = h("div");
|
||||
// Add form
|
||||
const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" });
|
||||
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } });
|
||||
const zoneName = zone.name.replace(/\.$/, "");
|
||||
const reverse = isReverseZone(zone.name);
|
||||
const rinfo = reverse ? reverseInfo(zone.name) : null;
|
||||
// Add form. In reverse zones records are named by address.
|
||||
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false" });
|
||||
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "A", { style: { width: "110px" } });
|
||||
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
|
||||
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
||||
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
|
||||
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
|
||||
const hint = h("div", { class: "hint" });
|
||||
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
|
||||
const suggest = h("div", { class: "aml-suggest" });
|
||||
const showHint = () => {
|
||||
const t = typeInfo[fType.value] || {};
|
||||
fData.placeholder = t.example || t.format || "";
|
||||
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
|
||||
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
|
||||
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
|
||||
};
|
||||
fType.addEventListener("change", showHint);
|
||||
fType.addEventListener("change", () => { showHint(); preview(); });
|
||||
showHint();
|
||||
// ownerOf shows what the Name field becomes.
|
||||
const ownerOf = (v) => {
|
||||
v = v.trim();
|
||||
if (reverse) {
|
||||
const r = ptrOwner(v || "@", zone.name);
|
||||
if (!v || v === "@") return { fqdn: zoneName };
|
||||
return r;
|
||||
}
|
||||
if (!v || v === "@") return { fqdn: zoneName };
|
||||
if (v.endsWith(".")) return { fqdn: v.replace(/\.$/, "").toLowerCase() };
|
||||
const low = v.toLowerCase();
|
||||
if (low === zoneName || low.endsWith("." + zoneName)) return { fqdn: low, note: "\"" + v + "\" already ends with the zone name, so it is read as " + low + " (end it with a dot to be explicit)" };
|
||||
return { fqdn: low + "." + zoneName };
|
||||
};
|
||||
// Which word of the data is a domain name, per type.
|
||||
const targetIndex = { CNAME: 0, DNAME: 0, NS: 0, PTR: 0, MX: 1, SRV: 3, KX: 1, AFSDB: 1 };
|
||||
const dataOf = (type, data) => {
|
||||
const words = data.trim().split(/\s+/);
|
||||
const i = targetIndex[type];
|
||||
if (i === undefined || words.length <= i || !words[i]) return { text: data.trim() };
|
||||
const w = words[i];
|
||||
let target, note, error;
|
||||
if (w === "@") target = zoneName + ".";
|
||||
else if (w.endsWith(".")) target = w;
|
||||
else if (reverse && type === "PTR") {
|
||||
if (w.includes(".")) target = w + ".";
|
||||
else error = "give the host's full name, e.g. " + w + ".adbraun.lol (" + w + " alone would mean " + w + "." + zoneName + ")";
|
||||
} else {
|
||||
target = w + "." + zoneName + ".";
|
||||
if (w.includes(".")) note = "\"" + w + "\" has no final dot, so it means " + target + "; write " + w + ". if you meant the name outside this zone";
|
||||
}
|
||||
if (error) return { error };
|
||||
words[i] = target;
|
||||
return { text: words.join(" "), note };
|
||||
};
|
||||
let lookupTimer = null, lookupFor = "";
|
||||
const preview = () => {
|
||||
const n = fName.value, d = fData.value;
|
||||
if (!n.trim() && !d.trim()) { mount(willAdd); mount(suggest); return; }
|
||||
const o = ownerOf(n);
|
||||
const dd = d.trim() ? dataOf(fType.value, d) : { text: "…" };
|
||||
const lines = [];
|
||||
if (o.error) lines.push(h("div", { class: "msg err small" }, o.error));
|
||||
if (dd.error) lines.push(h("div", { class: "msg err small" }, dd.error));
|
||||
if (o.note) lines.push(h("div", { class: "msg warn small" }, o.note));
|
||||
if (dd.note) lines.push(h("div", { class: "msg warn small" }, dd.note));
|
||||
if (reverse && o.fqdn && fType.value === "PTR") {
|
||||
const existing = current.filter((r) => r.type === "PTR" && r.name.replace(/\.$/, "") === o.fqdn && r.pending !== "delete");
|
||||
if (existing.length) lines.push(h("div", { class: "msg warn small" }, "this address already has a PTR record (" + existing.map((r) => r.data).join(", ") + "); a second one gives it two names"));
|
||||
}
|
||||
mount(willAdd, o.fqdn && !o.error ? h("div", null, h("span", { class: "muted" }, "Will add: "),
|
||||
h("code", null, o.fqdn + ". " + (fTTL.value !== "" ? fTTL.value + " " : "") + fType.value + " " + (dd.text || "")),
|
||||
reverse && addressOf(o.fqdn) ? h("span", { class: "muted" }, " (address " + addressOf(o.fqdn) + ")") : null) : null, lines);
|
||||
// In reverse zones, offer the names that already point to the address.
|
||||
const addr = reverse && o.fqdn ? addressOf(o.fqdn) : "";
|
||||
if (addr !== lookupFor) {
|
||||
lookupFor = addr;
|
||||
mount(suggest);
|
||||
clearTimeout(lookupTimer);
|
||||
if (addr && fType.value === "PTR") lookupTimer = setTimeout(async () => {
|
||||
const hits = await get("/search", { q: addr }).catch(() => []);
|
||||
if (lookupFor !== addr) return;
|
||||
const names = [...new Set(hits.filter((x) => x.kind === "record").map((x) => x.label.split(" ")).filter((l) => (l[1] === "A" || l[1] === "AAAA") && l[2] === addr).map((l) => l[0]))];
|
||||
mount(suggest, names.length ? [h("span", { class: "muted small" }, "Hosts with this address:"),
|
||||
names.map((nm) => h("button", { type: "button", class: "chip", onclick: () => { fData.value = nm.replace(/\.$/, "") + "."; preview(); fData.focus(); } }, nm))]
|
||||
: h("span", { class: "muted small" }, "No A/AAAA record points to " + addr + " yet. Tip: adding the A record with \"PTR\" ticked creates this PTR for you."));
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
for (const f of [fName, fData, fTTL]) f.addEventListener("input", preview);
|
||||
const add = async (e) => {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
const body = { name: fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
|
||||
const o = ownerOf(fName.value);
|
||||
const dd = dataOf(fType.value, fData.value);
|
||||
if (o.error || dd.error) { mount(err, h("div", { class: "msg err" }, o.error || dd.error)); (o.error ? fName : fData).focus(); return; }
|
||||
const body = { name: reverse && o.relative ? o.relative : fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
|
||||
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
|
||||
if (fComment.value.trim()) body.comment = fComment.value.trim();
|
||||
if (fPTR.input.checked) body.ptr = true;
|
||||
@@ -80,16 +159,22 @@ async function recordsTab(el, zone, query) {
|
||||
const r = await change("POST", "/zones/" + zone.id + "/records", body);
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||
// Start the next record from scratch (the type stays, as records
|
||||
// are often added several of a kind at a time).
|
||||
fName.value = "";
|
||||
fData.value = "";
|
||||
fTTL.value = "";
|
||||
fComment.value = "";
|
||||
lookupFor = "";
|
||||
preview();
|
||||
await redraw();
|
||||
fData.focus();
|
||||
fName.focus();
|
||||
} catch (ex) {
|
||||
mount(err, errorBox(ex));
|
||||
}
|
||||
};
|
||||
const form = h("form", { class: "inline-form", onsubmit: add },
|
||||
h("div", { class: "f" }, h("label", null, "Name"), fName),
|
||||
h("div", { class: "f" }, h("label", null, reverse ? "Address" : "Name"), fName),
|
||||
h("div", { class: "f" }, h("label", null, "Type"), fType),
|
||||
h("div", { class: "f" }, h("label", null, "TTL"), fTTL),
|
||||
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
|
||||
@@ -112,7 +197,7 @@ async function recordsTab(el, zone, query) {
|
||||
const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value));
|
||||
let prevName = null;
|
||||
mount(list, table([
|
||||
{ label: "Name", render: (r) => r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
|
||||
{ label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
|
||||
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
||||
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
||||
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
||||
@@ -186,10 +271,13 @@ async function recordsTab(el, zone, query) {
|
||||
filter.addEventListener("input", draw);
|
||||
typeFilter.addEventListener("change", draw);
|
||||
mount(el,
|
||||
h("div", { class: "panel" }, form, hint, err),
|
||||
h("div", { class: "panel" },
|
||||
reverse && rinfo ? h("div", { class: "muted small", style: { marginBottom: "6px" } }, "Reverse zone for " + rinfo.network + ": name records by address (e.g. " + rinfo.example + ") and give the host name as data.") : null,
|
||||
form, willAdd, suggest, hint, err),
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter,
|
||||
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute.")),
|
||||
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). " +
|
||||
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
|
||||
list));
|
||||
draw();
|
||||
fName.focus();
|
||||
|
||||
Reference in new issue
Block a user