// 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); }