- 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>
72 lines
4.0 KiB
JavaScript
72 lines
4.0 KiB
JavaScript
// Address match list helpers: suggestions for the ACLs, TSIG keys and
|
|
// built-in names that can be used where named expects an address match
|
|
// list (options such as allow-query, view match-clients, ACL elements),
|
|
// and a check for names that don't exist, so nobody has to switch pages
|
|
// to look up an ACL's exact name.
|
|
import { h, mount } from "./ui.js";
|
|
import { get } from "./api.js";
|
|
|
|
export const builtins = ["any", "none", "localhost", "localnets"];
|
|
|
|
// amlContext loads the names that can be referred to. Users who may not
|
|
// read ACLs or keys simply get fewer suggestions.
|
|
export async function amlContext() {
|
|
const [acls, keys] = await Promise.all([get("/acls").catch(() => []), get("/tsig-keys").catch(() => [])]);
|
|
return { acls: acls.map((a) => ({ name: a.name, detail: a.elements.join("; ") })), keys: keys.map((k) => k.name) };
|
|
}
|
|
|
|
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
|
|
|
|
// checkElement returns a problem with one element, or "".
|
|
function checkElement(line, ctx, exclude) {
|
|
let e = line.trim().replace(/;$/, "").trim();
|
|
if (!e) return "";
|
|
if (e.startsWith("!")) e = e.slice(1).trim();
|
|
const lower = e.toLowerCase();
|
|
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
|
|
const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
|
|
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
|
|
if (/\s/.test(e)) return "";
|
|
const name = e.replace(/^"|"$/g, "");
|
|
if (exclude && name.toLowerCase() === exclude.toLowerCase()) return "an ACL can't include itself";
|
|
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
|
|
}
|
|
|
|
// suggestions adds clickable names below a text field. mode "lines" adds
|
|
// one element per line (and checks the names); "inline" inserts
|
|
// "name; " at the cursor in named.conf syntax.
|
|
export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
|
|
const problems = h("div", { class: "aml-problems" });
|
|
const insert = (text) => {
|
|
if (mode === "lines") {
|
|
const lines = field.value.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
if (!lines.includes(text)) lines.push(text);
|
|
field.value = lines.join("\n");
|
|
} else {
|
|
const at = field.selectionStart ?? field.value.length;
|
|
const before = field.value.slice(0, at), after = field.value.slice(at);
|
|
const sep = before && !/[\s{]$/.test(before) ? " " : "";
|
|
field.value = before + sep + text + "; " + after;
|
|
field.selectionStart = field.selectionEnd = (before + sep + text + "; ").length;
|
|
}
|
|
field.focus();
|
|
field.dispatchEvent(new Event("input"));
|
|
};
|
|
const chip = (label, text, title) => h("button", { type: "button", class: "chip", title: title || "", onclick: () => insert(text) }, label);
|
|
const acls = ctx.acls.filter((a) => a.name.toLowerCase() !== exclude.toLowerCase());
|
|
const row = h("div", { class: "aml-suggest" },
|
|
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
|
|
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
|
|
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
|
|
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it.") : null);
|
|
if (mode === "lines") {
|
|
const check = () => {
|
|
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
|
|
mount(problems, msgs.map(([l, m]) => h("div", { class: "msg warn small" }, l + ": " + m)));
|
|
};
|
|
field.addEventListener("input", check);
|
|
check();
|
|
}
|
|
return h("div", { class: "aml" }, row, problems);
|
|
}
|