Multi-server CLI, more reverse zones, and a fix for new view directories
- CLI: name several servers and groups in the config file; select them with -s/--server NAME|GROUP|all|URL or JARL_SERVER. A command aimed at several servers is dry-run on all of them first and only runs if every one accepts it. Named servers always use their own token. - Reverse zones: /32 and /128 get single-address zones; networks between octet or nibble boundaries (e.g. a /22) are covered by several zones created together, from the CLI and the web UI. - Fix: under jarl.service's RestrictSUIDSGID, creating a view directory failed on chmod with the setgid bit; fall back to the permission bits. - Web UI: the review/live switch is a small header button, and the pending-changes bar only shows when there are pending changes. - TSIG key wording; the keys page points to restricted API tokens. - Docs: several servers (cli.md), a secondary server (operations.md), tokens vs TSIG keys (permissions.md); CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7d389be27
commit
785b0006f9
22 files changed
+1375
-185
No files matched your search
@@ -39,28 +39,57 @@ export function parseIPv6(s) {
|
||||
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
|
||||
// zone. It returns {name} or {error}.
|
||||
// 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" };
|
||||
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);
|
||||
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 = 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" };
|
||||
}
|
||||
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() };
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
return { error: "not an IPv4 or IPv6 network" };
|
||||
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) {
|
||||
@@ -73,16 +102,17 @@ 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;
|
||||
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(".") };
|
||||
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+(?=.)/, ""));
|
||||
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: groups.slice(0, 4).join(":") + "::20" };
|
||||
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;
|
||||
}
|
||||
@@ -101,6 +131,8 @@ export function ptrOwner(input, zoneName) {
|
||||
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(/\.$/, "") };
|
||||
|
||||
Reference in new issue
Block a user