New reverse zone: pick from the networks your records use, sized around them

The Zones page's New reverse zone lists every network the records use without a reverse zone, in every view, also where nothing is suggested (suggestions off, dismissed, public views), marked. Picking one fills in the network and view; size buttons (/48 /56 /64 /128, /16 /24 /32) re-mask it around a record's address, so a /48 becomes the records' own /64. Fill stays on. jarl zone suggest-reverse --all [--view V]; GET /reverse-zones/suggestions?all=1&view=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 11:20:03 +01:00
1 parent b1719ce362
commit 304c89dba4
12 files changed
+228 -31

No files matched your search

+60 -11
View File
@@ -1,7 +1,7 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
import { checkDomain, reverseZoneFor, maskNetwork } from "../names.js";
export async function render(el, params, query) {
const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"),
@@ -39,7 +39,8 @@ export async function render(el, params, query) {
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
can("zones", true) ? [h("button", { title: "A zone for the PTR records of a network: Jarl lists the networks your records use", onclick: () => newZoneDialog(views, zones, settings, { kind: "reverse" }) }, "New reverse zone"), " ",
h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone")] : null)),
h("div", { class: "panel" }, list),
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
draw();
@@ -51,16 +52,25 @@ export async function render(el, params, query) {
}
}
// preset.view picks the view (opened from a view's page).
// preset.view picks the view (opened from a view's page), preset.kind
// "reverse" starts with a reverse zone.
export async function newZoneDialog(views, zones, settings, preset = {}) {
const err = h("div");
// The zone's name matters most and is easy to get wrong, so the dialog
// asks for a domain or a network and shows the resulting zone name.
let kind = "forward";
let kind = preset.kind === "reverse" ? "reverse" : "forward";
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
const nameLabel = h("label", { for: "zone-name" }, "Domain");
name.id = "zone-name";
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
// For a reverse zone: sizes to pick from, and the networks the records
// use without a reverse zone, in every view (docs/reverse.md).
const helper = h("div", { class: "reverse-helper" });
let cands = null;
// sizeFrom is the address the size buttons work from: a record's, once a
// network was picked, so a /48 made smaller becomes that record's /64,
// not the /48's first one.
let sizeFrom = null;
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
@@ -149,6 +159,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
syncFill();
if (k === "reverse" && !cands) get("/reverse-zones/suggestions", draftQuery({ all: 1 })).catch(() => []).then((c) => { cands = c; drawHelper(); });
drawHelper();
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
check();
name.focus();
@@ -156,14 +168,46 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
}
name.addEventListener("input", () => { mount(err); updateClones(); check(); });
view.addEventListener("change", check);
const changed = () => { mount(err); updateClones(); check(); drawHelper(); };
const typed = () => { sizeFrom = null; changed(); };
const drawHelper = () => {
if (kind !== "reverse") return mount(helper);
const v = name.value.trim();
const isNet = v && maskNetwork(v, 32) !== null;
const v6 = v.includes(":");
const bits = Number(v.split("/")[1]);
const sizes = isNet ? h("div", { class: "size-chips" }, h("span", { class: "muted small" }, "Size: "),
(v6 ? [[48, "a whole site (all its /64s)"], [56, "a smaller delegation"], [64, "one subnet"], [128, "one address"]] : [[16, "a /16"], [24, "one network"], [32, "one address"]])
.map(([b, title]) => h("button", { type: "button", class: "small" + (bits === b ? " active" : ""), title, onclick: () => { name.value = maskNetwork(sizeFrom || name.value, b); changed(); } }, "/" + b)),
sizeFrom ? h("span", { class: "muted small" }, " around " + sizeFrom) : null) : null;
const list = (cands || []).filter((c) => !view.value || c.view === view.value);
const shown = list.slice(0, 12);
mount(helper, sizes,
shown.length ? h("div", { class: "cands" },
h("div", { class: "muted small" }, "Networks your records use without a reverse zone" + (view.value ? " in view " + view.value : "") + ": pick one, then change its size if you need"),
shown.map((c) => h("button", { type: "button", class: "cand", title: c.why, onclick: () => {
name.value = c.prefix;
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
if (views.length && c.view) view.value = c.view;
changed();
} },
h("span", { class: "mono" }, c.prefix),
views.length && !view.value ? h("span", { class: "muted" }, " " + c.view) : null,
h("span", { class: "muted small" }, " " + c.fill + " PTR" + (c.samples.length ? " · " + c.samples.slice(0, 2).join(", ") : "")),
c.hidden ? [" ", badge(c.hidden, "off")] : null)),
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more (pick a view to narrow the list)") : null)
: cands && !list.length ? h("div", { class: "muted small" }, "Every address your records use" + (view.value ? " in view " + view.value : "") + " has a reverse zone.") : null);
};
name.addEventListener("input", typed);
view.addEventListener("change", () => { check(); drawHelper(); });
const glueRow = field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue).");
const cloneRow = field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).");
const rows = {
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
glueRow,
field("SOA contact", contact, "Email address of the zone's administrator."),
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).")],
cloneRow],
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
stub: [field("Primary servers", primaries, "One per line.")],
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
@@ -173,7 +217,11 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
};
const typeRows = h("div", { class: "form-grid" });
const syncFill = () => { for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none"; };
const syncFill = () => {
for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none";
// Glue and copying a zone from another view are for domains.
for (const n of [...glueRow, ...cloneRow]) if (n) n.style.display = kind === "reverse" ? "none" : "";
};
const drawType = () => {
syncFill();
mount(typeRows, rows[type.value] || []);
@@ -185,14 +233,15 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
const body = h("div", null,
h("div", { class: "form-grid" },
h("label", null, "Kind"), h("div", null, kindButtons),
nameLabel, h("div", null, name, preview),
nameLabel, h("div", { class: "grid-cell" }, name, preview, helper),
views.length ? field("View", view) : null,
field("Type", type),
fillRow,
field("Comment", comment)),
h("div", { style: { marginTop: "8px" } }, typeRows),
err);
await modal("New zone", body, [
if (kind === "reverse") setKind("reverse");
await modal(kind === "reverse" ? "New reverse zone" : "New zone", body, [
{ label: "Cancel", value: null },
{ label: "Create zone", kind: "primary", onclick: async () => {
const t = type.value;