- New zone dialog: choose Domain or Reverse zone. Reverse zones are created from a network and their name is generated; both kinds show the exact zone name and flag likely mistakes (addresses or URLs as names, host-like names, duplicates in the view, parent/child zones, networks off the octet/nibble boundaries) before anything is submitted. - Record form: clears every field but the type after an add; shows the record exactly as it will be stored and warns about unqualified names. In reverse zones it takes an address, defaults to PTR, suggests host names of A/AAAA records with that address and warns about a second PTR; the record list shows addresses. - Address match lists (options, view match lists, ACL elements) offer the existing ACLs, TSIG keys and built-ins as one-click suggestions and flag unknown names. - Server: in reverse zones a record can be named by its address, and a PTR target like nas.example.com without the final dot is read as the full host name (a bare label is refused). Warnings noticed while parsing record data reach the result again. - Fixes: brand link hover underline, the dashboard's "New zone" link opening the dialog twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
7.8 KiB
JavaScript
153 lines
7.8 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("");
|
|
}
|
|
|
|
// reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse
|
|
// zone. It returns {name} 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" };
|
|
const bits = Number(bitsText);
|
|
const b4 = parseIPv4(addr);
|
|
if (b4) {
|
|
if (![8, 16, 24].includes(bits)) return { error: "IPv4 reverse zones follow the dots: use /8, /16 or /24 (a /" + bits + " network goes in the enclosing /" + (bits > 24 ? 24 : bits > 16 ? 16 : 8) + " zone)" };
|
|
const n = bits / 8;
|
|
const host = b4.slice(n);
|
|
if (host.some((x) => x !== 0)) return { error: "that's an address inside the network; the network is " + b4.slice(0, n).concat(host.map(() => 0)).join(".") + "/" + bits };
|
|
return { name: b4.slice(0, n).reverse().join(".") + ".in-addr.arpa", network: b4.slice(0, n).join(".") + (n < 4 ? "." + Array(4 - n).fill(0).join(".") : "") + "/" + bits };
|
|
}
|
|
const n6 = parseIPv6(addr);
|
|
if (n6) {
|
|
if (!(bits > 0 && bits <= 124 && bits % 4 === 0)) return { error: "IPv6 reverse zones need a prefix that is a multiple of 4, e.g. /48 or /64" };
|
|
const keep = n6.slice(0, bits / 4);
|
|
if (n6.slice(bits / 4).some((x) => x !== "0")) return { error: "the address has bits set after /" + bits + "; use the network address" };
|
|
return { name: keep.slice().reverse().join(".") + ".ip6.arpa", network: cidr.trim() };
|
|
}
|
|
return { error: "not an IPv4 or IPv6 network" };
|
|
}
|
|
|
|
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 > 3 || 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(".") };
|
|
}
|
|
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+(?=.)/, ""));
|
|
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: groups.slice(0, 4).join(":") + "::20" };
|
|
}
|
|
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(".");
|
|
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 };
|
|
}
|