Files
jarl/internal/web/static/assets/aml.js
T
abrown38andClaude Opus 5.5 bf7f99874b 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>
2026-10-07 20:49:43 +00:00

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