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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 23:17:14 +01:00
1 parent 49b1c872c9
commit e2e5007f94
32 files changed
+905 -31

No files matched your search

+32 -2
View File
@@ -16,6 +16,8 @@ export async function amlContext() {
}
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) {
@@ -24,6 +26,7 @@ function checkElement(line, ctx, exclude) {
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 "";
@@ -32,6 +35,33 @@ function checkElement(line, ctx, exclude) {
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.
@@ -58,7 +88,7 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
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);
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);
@@ -67,5 +97,5 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
field.addEventListener("input", check);
check();
}
return h("div", { class: "aml" }, row, problems);
return h("div", { class: "aml" }, row, problems, rangeHints(field));
}
+1 -1
View File
@@ -30,7 +30,7 @@ async function aclDialog(a) {
const comment = input({ value: a ? a.comment : "" });
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
field("Name", name, a ? "Renaming updates every reference automatically." : ""),
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), range (10.0.10.101-10.0.10.199), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
+7 -5
View File
@@ -3,6 +3,7 @@ import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge
import { get, change, describeResult, draftQuery, can, api } from "../api.js";
import { rerender } from "../app.js";
import { pendingBadge } from "./zones.js";
import { rangeHints } from "../aml.js";
// Well-known lists in Adblock format (they say which entries cover
// subdomains). Sizes change; these are rough guides.
@@ -355,7 +356,7 @@ async function del(path, what) {
function devicesPanel(groups, steps, views, autoViews, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
const members = input({ placeholder: "10.10.0.50 10.10.0.51 or an ACL", class: "mono" });
const members = input({ placeholder: "10.10.0.50 10.10.0.20-10.10.0.29 or an ACL", class: "mono" });
const step = stepSelect(steps, live.length ? live[0].name : "off", true);
const vp = viewPicker(views, autoViews);
const err = h("div");
@@ -368,7 +369,7 @@ function devicesPanel(groups, steps, views, autoViews, writable) {
} catch (e) { mount(err, errorBox(e)); }
} },
h("div", { class: "f" }, h("label", null, "Group"), name),
h("div", { class: "f grow" }, h("label", null, "Devices"), members),
h("div", { class: "f grow" }, h("label", null, "Devices"), members, rangeHints(members)),
h("div", { class: "f" }, h("label", null, "Step"), step),
vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
@@ -389,9 +390,10 @@ function devicesPanel(groups, steps, views, autoViews, writable) {
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
}
// splitMembers turns "a, b c" into a list.
// splitMembers turns "a, b c" into a list; a range may have spaces
// around its dash.
function splitMembers(s) {
return s.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
return s.replace(/\s*-\s*/g, "-").split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
}
// stepsLine shows the steps in order, with controls for the ones above
@@ -463,7 +465,7 @@ function editGroup(g, steps, views, autoViews) {
const vp = viewPicker(views, autoViews, ownViews(g));
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
["Name", name],
["Devices", members, "Addresses, networks (10.10.0.0/24) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
["Devices", h("div", null, members, rangeHints(members)), "Addresses, networks (10.10.0.0/24), ranges (10.10.0.20-10.10.0.49) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
["Step", step],
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
["Comment", comment],
+1 -1
View File
@@ -25,7 +25,7 @@ function valueWidget(stmt, value, ctx) {
if (kind === "address_match_list") {
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address, prefix or range (10.0.10.101-10.0.10.199), !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
}
if (kind === "raw") {
const t = codeArea(value || "");
+1 -1
View File
@@ -128,7 +128,7 @@ async function viewForm(v, viewless) {
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
const body = h("div", null, h("div", { class: "form-grid" },
field("Name", name),
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ranges (10.0.10.101-10.0.10.199), ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
field("Recursive only", mro, "If yes, only recursive queries match this view."),
field("Comment", comment),
+5
View File
@@ -426,6 +426,11 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
.range-hints .msg { margin: 4px 0 0; padding: 3px 8px; }
.range-hint { margin-top: 4px; color: var(--muted); }
.range-hint summary { cursor: pointer; }
.range-hint .prefixes { display: flex; flex-wrap: wrap; gap: 2px 12px; margin: 2px 0 0 14px; }
.range-hint .prefixes span { white-space: nowrap; }
/* Zone name and record previews. */
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }