Reverse zones made easy: suggested, filled, in the right view, PTRs kept in step
Missing reverse zones are suggested for the private views' A and AAAA records (/24 for private IPv4, /32 for a public address, /48 for IPv6) and created filled with PTR records (jarl zone suggest-reverse, the Zones page). zone create --fill and zone fill-ptr add the PTRs a reverse zone lacks; a reverse zone created without a view goes to the private view using its addresses. Per zone, auto_ptr keeps the PTR records of A and AAAA records in step on every change (database version 12). Zone commands take a network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3b054cc86
commit
aa4b004a14
27 files changed
+1173
-38
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
|
||||
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js";
|
||||
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
import { emailOf, pendingBadge } from "./zones.js";
|
||||
@@ -34,6 +34,8 @@ export async function render(el, params, query) {
|
||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
|
||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
|
||||
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "ok")), " "] : null,
|
||||
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
||||
zone.links ? [h("span", { class: "badge accent" }, "linked with ",
|
||||
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
|
||||
@@ -46,7 +48,10 @@ export async function render(el, params, query) {
|
||||
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
|
||||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
||||
const banner = zone.disabled ? h("div", { class: "msg warn" },
|
||||
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
|
||||
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.")
|
||||
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
|
||||
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
|
||||
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : null;
|
||||
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
||||
// The header and tabs show at once. Below them, switching tabs keeps the
|
||||
// previous tab's content (faded) until the new one is loaded, rather
|
||||
@@ -442,7 +447,7 @@ async function recordsTab(el, zone, query) {
|
||||
const t = typeInfo[fType.value] || {};
|
||||
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
|
||||
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
|
||||
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
|
||||
fPTR.classList.toggle("hidden", zone.auto_ptr || !(fType.value === "A" || fType.value === "AAAA"));
|
||||
};
|
||||
fType.addEventListener("change", () => { showHint(); preview(); });
|
||||
showHint();
|
||||
@@ -622,7 +627,9 @@ async function recordsTab(el, zone, query) {
|
||||
const del = async (r) => {
|
||||
const isAddr = r.type === "A" || r.type === "AAAA";
|
||||
let ptr = false;
|
||||
if (isAddr) {
|
||||
if (isAddr && zone.auto_ptr) {
|
||||
if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data + ", and its PTR record (this zone keeps them in step)", "Delete", "danger"))) return;
|
||||
} else if (isAddr) {
|
||||
const c = checkbox("Also delete the matching PTR record", true);
|
||||
const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
|
||||
if (ok !== true) return;
|
||||
@@ -693,7 +700,7 @@ async function recordsTab(el, zone, query) {
|
||||
h("div", { class: "f" }, h("label", null, "TTL"), eTTL),
|
||||
h("div", { class: "f grow" }, h("label", null, "Data"), eData),
|
||||
h("div", { class: "f" }, h("label", null, "Comment"), eComment),
|
||||
(r.type === "A" || r.type === "AAAA") ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
|
||||
(r.type === "A" || r.type === "AAAA") && !zone.auto_ptr ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
|
||||
h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))),
|
||||
h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."),
|
||||
eErr);
|
||||
@@ -733,6 +740,7 @@ async function settingsTab(el, zone) {
|
||||
const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || ""));
|
||||
const file = input({ value: zone.file || "", class: "mono" });
|
||||
const comment = textarea(zone.comment, { rows: 2 });
|
||||
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
|
||||
if (zone.type === "primary") {
|
||||
rows.push(
|
||||
field("Primary name server", mname, "SOA MNAME."),
|
||||
@@ -743,6 +751,8 @@ async function settingsTab(el, zone) {
|
||||
field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."),
|
||||
field("Default TTL", ttl, "TTL for records that don't set their own."),
|
||||
field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes."));
|
||||
if (!zone.prefix && !/\.(in-addr|ip6)\.arpa\.$/.test(zone.name)) rows.push(field("Reverse DNS", autoPTR,
|
||||
"Adding, changing, disabling or deleting an address record does the same to its PTR record, in the reverse zones of " + (zone.view ? "view " + zone.view + (zone.linked_with ? " and the linked views" : "") : "the server") + ". An address keeps the name it has; one without a reverse zone is left alone (Zones lists the reverse zones you're missing)."));
|
||||
}
|
||||
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones)."));
|
||||
if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it."));
|
||||
@@ -754,6 +764,7 @@ async function settingsTab(el, zone) {
|
||||
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
|
||||
body.default_ttl = +ttl.value;
|
||||
body.serial_policy = policy.value;
|
||||
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
|
||||
}
|
||||
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim();
|
||||
if (views.length) body.view_id = view.value ? Number(view.value) : 0;
|
||||
@@ -824,3 +835,13 @@ async function toggleZone(zone) {
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
|
||||
async function fillPTR(zone) {
|
||||
try {
|
||||
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr");
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox } from "../ui.js";
|
||||
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";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
|
||||
const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"),
|
||||
get("/reverse-zones/suggestions", draftQuery()).catch(() => [])]);
|
||||
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
||||
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
||||
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
||||
@@ -23,7 +24,7 @@ export async function render(el, params, query) {
|
||||
lastList.zones = hash;
|
||||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||||
mount(list, table([
|
||||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : 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: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.prefix ? h("span", { class: "muted small" }, " " + z.prefix) : null, z.ptr_missing ? [" ", badge(z.ptr_missing + " PTR missing", "warn")] : null, dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||||
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
|
||||
@@ -39,7 +40,8 @@ export async function render(el, params, query) {
|
||||
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)),
|
||||
h("div", { class: "panel" }, list));
|
||||
h("div", { class: "panel" }, list),
|
||||
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
|
||||
draw();
|
||||
if (query.get("new")) {
|
||||
// Drop ?new=1 so re-rendering after the zone is created doesn't open
|
||||
@@ -77,6 +79,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
|
||||
cloneSel.addEventListener("change", syncLink);
|
||||
const comment = input();
|
||||
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
|
||||
const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
|
||||
const updateClones = () => {
|
||||
// Records can only be copied into a single zone.
|
||||
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
||||
@@ -144,6 +148,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
|
||||
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 (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
|
||||
check();
|
||||
name.focus();
|
||||
};
|
||||
@@ -167,7 +173,9 @@ 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 drawType = () => {
|
||||
syncFill();
|
||||
mount(typeRows, rows[type.value] || []);
|
||||
if (type.value === "hint" && !name.value.trim()) name.value = ".";
|
||||
check();
|
||||
@@ -180,6 +188,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
nameLabel, h("div", null, name, preview),
|
||||
views.length ? field("View", view) : null,
|
||||
field("Type", type),
|
||||
fillRow,
|
||||
field("Comment", comment)),
|
||||
h("div", { style: { marginTop: "8px" } }, typeRows),
|
||||
err);
|
||||
@@ -197,6 +206,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
if (view.value) data.view = view.value;
|
||||
const opts = [];
|
||||
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
||||
if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true;
|
||||
if (t === "primary") {
|
||||
data.nameservers = lines(ns.value);
|
||||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||||
@@ -229,6 +239,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
|
||||
: await change("POST", "/zones", data);
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -268,3 +279,28 @@ export function dnssecLock(z) {
|
||||
export function pendingBadge(kind) {
|
||||
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
||||
}
|
||||
|
||||
// suggestPanel lists the reverse zones the private views lack for their
|
||||
// records' addresses, to create filled with PTR records (docs/reverse.md).
|
||||
function suggestPanel(sugg, views) {
|
||||
const create = async (list) => {
|
||||
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
|
||||
try {
|
||||
const r = reqs.length === 1 ? await change("POST", "/zones", reqs[0].data) : await changeMany(reqs, plural(reqs.length, "reverse zone"));
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
return h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
|
||||
sugg.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(sugg) }, "Create all " + sugg.length)) : null),
|
||||
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
|
||||
table([
|
||||
views.length ? { label: "View", render: (s) => s.view } : null,
|
||||
{ label: "Network", render: (s) => [h("span", { class: "mono" }, s.prefix), h("div", { class: "muted small" }, s.why)] },
|
||||
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
|
||||
{ label: "PTR records", render: (s) => [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
|
||||
{ label: "", render: (s) => h("button", { class: "small", onclick: () => create([s]) }, "Create"), tdClass: "actions-cell" },
|
||||
].filter(Boolean), sugg));
|
||||
}
|
||||
Reference in new issue
Block a user