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