Files
jarl/internal/web/static/assets/names.js
T
adamandClaude Opus 5.5 304c89dba4 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>
2026-10-13 11:20:03 +01:00

199 lines
10 KiB
JavaScript

// DNS name helpers for forms: reverse zones from networks, PTR names from
// addresses, and plain checks of domain names, so the UI can show what a
// name will become before anything is submitted. The server checks
// everything again; these only make mistakes visible early.
const v4Re = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
export function parseIPv4(s) {
const m = s.trim().match(v4Re);
if (!m) return null;
const b = m.slice(1, 5).map(Number);
return b.every((x) => x <= 255) ? b : null;
}
// parseIPv6 returns the 32 hex nibbles of an address, or null.
export function parseIPv6(s) {
s = s.trim().toLowerCase();
if (!s.includes(":") || !/^[0-9a-f:.]+$/.test(s)) return null;
let tail = [];
const v4 = s.match(/:(\d+\.\d+\.\d+\.\d+)$/);
if (v4) {
const b = parseIPv4(v4[1]);
if (!b) return null;
tail = [((b[0] << 8) | b[1]).toString(16), ((b[2] << 8) | b[3]).toString(16)];
s = s.slice(0, -v4[1].length) + "0:0";
}
const halves = s.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
let groups;
if (halves.length === 2) {
const fill = 8 - head.length - rest.length;
if (fill < 1) return null;
groups = [...head, ...Array(fill).fill("0"), ...rest];
} else groups = head;
if (tail.length) groups.splice(-2, 2, ...tail);
if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.map((g) => g.padStart(4, "0")).join("").split("");
}
// maxReverseZones matches the server's limit for splitting a network.
const maxReverseZones = 16;
// reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse
// zones. Reverse names follow octets (IPv4) or nibbles (IPv6), so a /22 is
// covered by four /24 zones; a /32 gets a zone for just that address. It
// returns {names, network} (with name when there is a single zone) or
// {error}.
export function reverseZoneFor(cidr) {
const [addr, bitsText] = cidr.trim().split("/");
if (bitsText === undefined) return { error: "give the network with its prefix length, e.g. 10.10.0.0/24 (or 10.10.0.5/32 for one address)" };
const bits = Number(bitsText);
const b4 = parseIPv4(addr);
const n6 = b4 ? null : parseIPv6(addr);
if (!b4 && !n6) return { error: "not an IPv4 or IPv6 network" };
// Work on digits: octets for IPv4, nibbles for IPv6.
const digits = b4 || n6.map((x) => parseInt(x, 16));
const step = b4 ? 8 : 4, total = b4 ? 32 : 128;
if (!Number.isInteger(bits) || bits < 0 || bits > total) return { error: "the prefix length must be 0 to " + total };
if (bits < step) return { error: "/" + bits + " is too large for a reverse zone" };
if (b4 && bits > 24 && bits < 32) {
const enclosing = b4.slice(0, 3).join(".") + ".0/24";
return { error: "a /" + bits + " is part of " + enclosing + ", and all its reverse names live in that /24's zone: create " + enclosing + ", or a /32 zone per address to answer for single addresses only" };
}
// The host part must be zero.
for (let i = 0; i < digits.length; i++) {
const keepBits = Math.max(0, Math.min(step, bits - i * step));
if (digits[i] & ((1 << (step - keepBits)) - 1)) {
const net = digits.map((d, j) => { const k = Math.max(0, Math.min(step, bits - j * step)); return d & ~((1 << (step - k)) - 1); });
return { error: "the address has bits set after /" + bits + "; the network is " + (b4 ? net.join(".") : sixOf(net.map((d) => d.toString(16)))) + "/" + bits };
}
}
const zoneBits = Math.ceil(bits / step) * step;
const n = 2 ** (zoneBits - bits);
if (n > maxReverseZones) return { error: "a /" + bits + " spans " + n + " /" + zoneBits + " reverse zones; use a /" + (zoneBits - step) + " or create the zones you need" };
const keep = zoneBits / step;
const names = [];
for (let i = 0; i < n; i++) {
const d = digits.slice(0, keep);
d[keep - 1] += i;
names.push(d.map((x) => (b4 ? String(x) : x.toString(16))).reverse().join(".") + (b4 ? ".in-addr.arpa" : ".ip6.arpa"));
}
const network = (b4 ? b4.join(".") : sixOf(n6)) + "/" + bits;
return n === 1 ? { name: names[0], names, network } : { names, network };
}
// sixOf formats 32 nibbles as a compressed IPv6 address.
function sixOf(nib) {
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(nib.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
return groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
}
export function isReverseZone(name) {
const n = name.replace(/\.$/, "").toLowerCase();
return n.endsWith(".in-addr.arpa") || n.endsWith(".ip6.arpa");
}
// reverseInfo describes the network a reverse zone covers.
export function reverseInfo(zoneName) {
const n = zoneName.replace(/\.$/, "").toLowerCase();
if (n.endsWith(".in-addr.arpa")) {
const labels = n.slice(0, -".in-addr.arpa".length).split(".").reverse();
if (labels.length > 4 || labels.some((l) => !/^\d+$/.test(l))) return null;
const pad = Array(4 - labels.length).fill("0");
return { family: 4, prefix: labels, network: [...labels, ...pad].join(".") + "/" + labels.length * 8, example: [...labels, ...pad.map((_, i) => (i === pad.length - 1 ? "20" : "0"))].join("."), single: labels.length === 4 };
}
if (n.endsWith(".ip6.arpa")) {
const nib = n.slice(0, -".ip6.arpa".length).split(".").reverse();
const full = [...nib, ...Array(32 - nib.length).fill("0")];
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(full.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
const single = nib.length === 32;
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: single ? sixOf(full) : groups.slice(0, 4).join(":") + "::20", single };
}
return null;
}
// ptrOwner turns an address (or a relative label) into the record name
// inside a reverse zone: {relative, fqdn} or {error}.
export function ptrOwner(input, zoneName) {
const zone = zoneName.replace(/\.$/, "");
const info = reverseInfo(zone);
const s = input.trim();
if (!info || !s) return {};
const b4 = info.family === 4 ? parseIPv4(s) : null;
const n6 = info.family === 6 ? parseIPv6(s) : null;
if (b4 || n6) {
const parts = b4 ? b4.map(String) : n6;
const inZone = info.prefix.every((p, i) => parts[i] === p);
if (!inZone) return { error: s + " is not in " + info.network + ", the network this zone covers" };
const rel = parts.slice(info.prefix.length).reverse().join(".");
// In a single-address zone the address is the zone's apex.
if (!rel) return { relative: "@", fqdn: zone };
return { relative: rel, fqdn: rel + "." + zone };
}
if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") };
return { relative: s, fqdn: s + "." + zone };
}
// addressOf rebuilds the address a PTR name in a reverse zone stands for.
export function addressOf(fqdn) {
const n = fqdn.replace(/\.$/, "").toLowerCase();
if (n.endsWith(".in-addr.arpa")) {
const l = n.slice(0, -".in-addr.arpa".length).split(".");
return l.length === 4 && l.every((x) => /^\d+$/.test(x)) ? l.reverse().join(".") : "";
}
if (n.endsWith(".ip6.arpa")) {
const l = n.slice(0, -".ip6.arpa".length).split(".");
if (l.length !== 32) return "";
const hex = l.reverse().join("");
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(hex.slice(i, i + 4).replace(/^0+(?=.)/, ""));
return groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
}
return "";
}
// checkDomain checks a zone name as typed and returns {name, problems,
// notes}: name is the normalized form (lowercase, no final dot).
export function checkDomain(input) {
const raw = input.trim();
const name = raw.toLowerCase().replace(/\.$/, "");
const problems = [], notes = [];
if (!name) return { name, problems, notes };
if (/\s/.test(name)) problems.push("names can't contain spaces");
if (/^[a-z]+:\/\//.test(name) || name.includes("/")) problems.push("give just the domain name, not a URL or path");
if (name.includes("@")) problems.push("that looks like an email address");
if (parseIPv4(name) || parseIPv6(name)) problems.push("that's an address: for a reverse zone choose \"Reverse zone\" and give the network");
const labels = name.split(".");
if (labels.some((l) => l === "")) problems.push("empty label (two dots in a row)");
if (labels.some((l) => l.length > 63)) problems.push("each part can be at most 63 characters");
if (name.length > 253) problems.push("the name is too long");
if (!problems.length && labels.some((l) => !/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?$/.test(l) && !/^xn--/.test(l))) {
if (/[^\x00-\x7f]/.test(name)) notes.push("international name: it will be stored in its xn-- (punycode) form");
else problems.push("use letters, digits and hyphens (no hyphen at the start or end of a part)");
}
if (raw !== name && raw.replace(/\.$/, "") !== raw.replace(/\.$/, "").toLowerCase()) notes.push("stored in lowercase");
if (!problems.length && labels.length === 1 && name !== "") notes.push("a single label: that's a top-level domain; usually you want your domain, e.g. " + name + "." + "example");
if (!problems.length && /^(www|mail|ns\d*|smtp|vpn)\./.test(name) && labels.length > 2) notes.push("\"" + labels[0] + "\" usually names a host inside a zone; is the zone " + labels.slice(1).join(".") + "?");
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;
}