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:
1 parent
b1719ce362
commit
304c89dba4
12 files changed
+228
-31
No files matched your search
@@ -182,3 +182,17 @@ export function checkDomain(input) {
|
||||
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
|
||||
return { name, problems, notes };
|
||||
}
|
||||
|
||||
// maskNetwork returns the network of the given size an address (or a
|
||||
// network, "2a02:8013:7f3d:10::/64") is in: maskNetwork("2a02:8013:7f3d:10::5", 48)
|
||||
// is "2a02:8013:7f3d::/48". null when it isn't an address.
|
||||
export function maskNetwork(input, bits) {
|
||||
const addr = String(input).trim().split("/")[0];
|
||||
const b4 = parseIPv4(addr);
|
||||
const n6 = b4 ? null : parseIPv6(addr);
|
||||
if (!b4 && !n6) return null;
|
||||
const digits = b4 || n6.map((x) => parseInt(x, 16));
|
||||
const step = b4 ? 8 : 4;
|
||||
const net = digits.map((d, i) => { const k = Math.max(0, Math.min(step, bits - i * step)); return d & ~((1 << (step - k)) - 1) & ((1 << step) - 1); });
|
||||
return (b4 ? net.join(".") : sixOf(net.map((d) => d.toString(16)))) + "/" + bits;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -582,3 +582,11 @@ table.compare tr.muted td { color: var(--muted); }
|
||||
|
||||
/* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */
|
||||
pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
/* New reverse zone: sizes and the networks the records use. */
|
||||
.reverse-helper { margin-top: 6px; }
|
||||
.form-grid > .grid-cell { min-width: 0; }
|
||||
.size-chips { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
|
||||
.cands button.cand { text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
Reference in new issue
Block a user