// 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; // rangeRe finds address ranges (START-END); the server checks them. const rangeRe = /!?\s*[0-9a-f]*[.:][0-9a-f:.]*\s*-\s*[0-9a-f]*[.:][0-9a-f:.]*/gi; // 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 ""; if (e.replace(rangeRe, "").trim() === "") return ""; // a range: rangeHints explains it 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)"); } // rangeHints shows, below a field, what each address range in it // (10.0.10.101-10.0.10.199) becomes in named.conf, which only knows // prefixes, and what is wrong with a range that can't be used. export function rangeHints(field) { const box = h("div", { class: "range-hints" }); let timer, last = ""; const update = async () => { const found = (field.value.match(rangeRe) || []).map((r) => r.replace(/\s+/g, "")); const key = found.join(","); if (key === last) return; last = key; if (!found.length) { mount(box); return; } try { const info = await get("/catalog/ranges", { r: key }); if (key !== last) return; mount(box, info.map((x) => x.error ? h("div", { class: "msg warn small" }, x.error) : h("details", { class: "range-hint small" }, h("summary", null, h("span", { class: "mono" }, x.input), " = " + x.size + ", written to named.conf as " + (x.prefixes.length === 1 ? "1 prefix" : x.prefixes.length + " prefixes")), h("div", { class: "prefixes mono" }, x.prefixes.map((p) => h("span", null, p)))))); } catch { mount(box); } }; field.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(update, 300); }); update(); return box; } // 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. Ranges: 10.0.10.101-10.0.10.199.") : 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, rangeHints(field)); }