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>
192 lines
12 KiB
JavaScript
192 lines
12 KiB
JavaScript
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker, backLink, safeBack, viewHref, badge } from "../ui.js";
|
|
import { amlContext, suggestions } from "../aml.js";
|
|
import { api, get, change, describeResult } from "../api.js";
|
|
import { newZoneDialog, dnssecLock, pendingBadge } from "./zones.js";
|
|
import { rerender } from "../app.js";
|
|
import { optionsEditor } from "./options.js";
|
|
|
|
export async function render(el, params, query) {
|
|
// Old links (#/views?view=ID) open the view's page.
|
|
if (query.get("view")) {
|
|
history.replaceState(null, "", viewHref(query.get("view"), query.get("back") || ""));
|
|
return rerender();
|
|
}
|
|
if (params.id) return viewPage(el, params, query);
|
|
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
|
|
const viewless = zones.filter((z) => !z.view_id);
|
|
const move = async (i, d) => {
|
|
const ids = views.map((v) => v.id);
|
|
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
|
try {
|
|
const r = await change("POST", "/views/order", { ids });
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
};
|
|
mount(el,
|
|
h("div", { class: "page-head" },
|
|
h("div", { class: "grow" }, h("h1", null, "Views"),
|
|
h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")),
|
|
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(viewless) }, "New view"))),
|
|
views.length === 0 ? h("div", { class: "msg info" },
|
|
"No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null,
|
|
h("div", { class: "panel" }, table([
|
|
{ label: "#", render: (v, i) => String(i + 1) },
|
|
{ label: "View", render: (v) => h("a", { href: viewHref(v.id), class: v.color ? "vc-" + v.color : null }, h("strong", null, v.name)) },
|
|
{ label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
|
{ label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
|
{ label: "Zones", render: (v) => h("a", { href: viewHref(v.id) + "/zones" }, String(v.zone_count)) },
|
|
{ label: "Comment", render: (v) => h("span", { class: "muted" }, v.comment || "") },
|
|
{ label: "Order", render: (v, i) => h("span", null,
|
|
h("button", { class: "small", disabled: i === 0, title: "Move up: clients are matched against it earlier", onclick: () => move(i, -1) }, "↑"), " ",
|
|
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓")), tdClass: "actions-cell" },
|
|
], views, { empty: "No views." })));
|
|
}
|
|
|
|
// viewPage is one view, laid out like a zone: its settings, its options
|
|
// and its zones, each on a tab.
|
|
async function viewPage(el, params, query) {
|
|
const id = Number(params.id);
|
|
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
|
|
const i = views.findIndex((v) => v.id === id);
|
|
const v = views[i];
|
|
const back = safeBack(query.get("back"));
|
|
if (!v) {
|
|
mount(el, backLink("Views", "#/views"), h("div", { class: "msg err" }, "View " + id + " doesn't exist (any more)."));
|
|
return;
|
|
}
|
|
const inView = zones.filter((z) => z.view_id === id);
|
|
const tab = params.tab || "settings";
|
|
const base = viewHref(id);
|
|
const suffix = back ? "?back=" + encodeURIComponent(back) : "";
|
|
const tabs = [["settings", "Settings"], ["options", "Options"], ["zones", "Zones (" + inView.length + ")"]];
|
|
const head = h("div", { class: "page-head" },
|
|
h("div", { class: "grow" },
|
|
back ? backLink(backLabel(back, zones), back) : backLink("Views", "#/views"),
|
|
h("h1", { class: v.color ? "vc-" + v.color : null }, v.name),
|
|
h("div", { class: "muted" },
|
|
"view " + (i + 1) + " of " + views.length + " · clients: " + ((v.match_clients || []).join("; ") || "any") +
|
|
((v.match_destinations || []).length ? " · destinations: " + v.match_destinations.join("; ") : "") +
|
|
(v.comment ? " · " + v.comment : ""))),
|
|
h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => deleteView(v) }, "Delete view")));
|
|
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: base + "/" + k + suffix, class: k === tab ? "active" : "" }, label)));
|
|
const body = h("div");
|
|
mount(el, head, tabBar, body);
|
|
if (tab === "options") {
|
|
await optionsEditor(body, { scope: "view", scope_id: id, catalogScope: "view", title: "View options",
|
|
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
|
|
} else if (tab === "zones") {
|
|
await zonesTab(body, v, views, zones, inView);
|
|
} else {
|
|
await settingsTab(body, v);
|
|
}
|
|
const first = body.querySelector("[data-autofocus]");
|
|
if (first) first.focus();
|
|
}
|
|
|
|
async function settingsTab(el, v) {
|
|
const f = await viewForm(v, []);
|
|
mount(el, h("div", { class: "panel" }, f.body,
|
|
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: async () => { if (await f.save()) rerender(); } }, "Save settings"))));
|
|
}
|
|
|
|
async function zonesTab(el, v, views, zones, inView) {
|
|
mount(el, h("div", { class: "panel" },
|
|
h("div", { class: "actions", style: { marginBottom: "10px" } },
|
|
h("button", { class: "primary", onclick: async () => newZoneDialog(views, zones, await get("/settings"), { view: v.name }) }, "New zone in " + v.name),
|
|
h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, "Show on the Zones page")),
|
|
table([
|
|
{ label: "Zone", render: (z) => [h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
|
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
|
{ label: "Records", render: (z) => (z.type === "primary" ? String(z.record_count) : ""), class: "right", tdClass: "right" },
|
|
{ label: "Serial", render: (z) => (z.type === "primary" ? String(z.soa.serial) : ""), tdClass: "mono" },
|
|
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
|
], inView, { empty: "No zones in this view yet.", rowClass: (z) => (z.disabled ? "disabled" : "") })));
|
|
}
|
|
|
|
// backLabel names the page a back address leads to.
|
|
function backLabel(back, zones) {
|
|
const m = back.match(/^#\/zones\/(\d+)/);
|
|
if (m) {
|
|
const z = zones.find((z) => String(z.id) === m[1]);
|
|
return z ? z.display_name + (z.view ? " (" + z.view + ")" : "") : "Zone";
|
|
}
|
|
return /^#\/zones(\?|$)/.test(back) ? "Zones" : "Back";
|
|
}
|
|
|
|
// viewForm is the form for a view's settings (v null: a new view); save
|
|
// sends it and reports whether it worked.
|
|
async function viewForm(v, viewless) {
|
|
const err = h("div");
|
|
const ctx = await amlContext();
|
|
const name = input({ value: v ? v.name : "", placeholder: "internal", "data-autofocus": v ? null : true });
|
|
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
|
|
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
|
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
|
const comment = input({ value: v ? v.comment : "" });
|
|
const color = colorPicker(v ? v.color : "");
|
|
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, 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),
|
|
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
|
|
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
|
err);
|
|
const save = async () => {
|
|
mount(err);
|
|
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
|
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
|
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
|
const colorChanged = color.value !== ((v && v.color) || "");
|
|
try {
|
|
let r = null;
|
|
if (!v || configChanged(v, data)) r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data);
|
|
const id = v ? v.id : (r.object && r.object.id) || ((r.added || [])[0] || {}).id;
|
|
if (colorChanged && id) {
|
|
// A view created in a draft only exists in that draft so far.
|
|
await api("PUT", "/views/" + id + "/color", { body: { color: color.value }, stage: r && r.staged ? r.changeset_id : undefined });
|
|
}
|
|
toast(r ? describeResult(r) : colorChanged ? "Colour saved" : "Nothing changed");
|
|
return true;
|
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
|
};
|
|
return { body, save };
|
|
}
|
|
|
|
async function viewDialog(viewless) {
|
|
const f = await viewForm(null, viewless);
|
|
await modal("New view", f.body, [
|
|
{ label: "Cancel", value: null },
|
|
{ label: "Create view", kind: "primary", onclick: async () => { const ok = await f.save(); if (ok) rerender(); return ok; } },
|
|
], { wide: true, sticky: true });
|
|
}
|
|
|
|
// configChanged tells whether the dialog changed the view's configuration
|
|
// (not just its colour), so a colour change alone stages nothing.
|
|
function configChanged(v, data) {
|
|
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
|
|
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
|
|
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
|
|
data.match_recursive_only !== mro || data.comment !== (v.comment || "");
|
|
}
|
|
|
|
async function deleteView(v) {
|
|
let cascade = false;
|
|
if (v.zone_count > 0) {
|
|
const c = checkbox("Also delete its " + v.zone_count + " zone(s) and their records", false);
|
|
const ok = await modal("Delete view " + v.name, h("div", null, h("p", null, "The view contains zones."), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
|
|
if (ok !== true) return;
|
|
cascade = c.input.checked;
|
|
} else if (!(await confirmDialog("Delete view", "Delete view " + v.name + "?", "Delete", "danger"))) return;
|
|
try {
|
|
const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined });
|
|
toast(describeResult(r));
|
|
// Applied at once, the view is gone; staged, it is still there.
|
|
if (r.applied) location.hash = "#/views";
|
|
else rerender();
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
}
|