Files
jarl/internal/web/static/assets/aml.js
T
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00

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