Files
jarl/internal/web/static/assets/names.js
T
abrown38andClaude Opus 5.5 bf7f99874b Web UI: zone naming and reverse zones, ACL suggestions, record form
- 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>
2026-10-07 20:49:43 +00:00

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 };
}