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:
abrown38andClaude Opus 5.5 committed 2026-10-08 08:12:03 +00:00
1 parent a7d389be27
commit 785b0006f9
22 files changed
+1375 -185

No files matched your search

+19 -14
View File
@@ -85,6 +85,7 @@ function renderLogin(message) {
function layout() {
navEl = h("nav", { class: "main" });
let modeEl;
barEl = h("div");
mainEl = h("main");
const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" });
@@ -96,38 +97,42 @@ function layout() {
h("span", { class: "brand-version" }, me.version)),
navEl,
h("div", { class: "spacer" }),
modeEl = h("span"),
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { class: "small", onclick: logout }, "Log out"));
mount(app, header, barEl, mainEl);
modeHolder = modeEl;
renderNav();
renderBar();
}
let modeHolder;
function renderNav() {
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
}
// renderBar shows the pending-changes bar (only while there are pending
// changes) and the small review/live switch in the header.
function renderBar() {
if (!barEl) return;
const d = state.draft;
const n = d ? (d.ops || []).length : 0;
const mode = h("label", { class: "check small", title: "When on, every edit is applied to named right away instead of being collected for review." },
h("input", { type: "checkbox", checked: state.immediate, onchange: (e) => setImmediate(e.target.checked) }), "Apply edits immediately");
if (n > 0) {
mount(barEl, h("div", { class: "pending-bar" },
h("strong", null, plural(n, "pending change")),
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply"),
h("div", { class: "spacer", style: { flex: 1 } }),
mode));
} else {
mount(barEl, h("div", { class: "pending-bar", style: { background: "var(--panel)" } },
h("span", { class: "muted small" }, state.immediate ? "Edits are applied to named immediately." : "Edits are collected as pending changes; review and apply them under Changes."),
h("div", { style: { flex: 1 } }),
mode));
if (modeHolder) {
const live = state.immediate;
mount(modeHolder, h("button", {
type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false",
title: live ? "Edits are applied to named right away. Click to collect them for review first."
: "Edits are collected as pending changes and applied from Changes. Click to apply edits right away.",
onclick: () => setImmediate(!live),
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
}
mount(barEl, n > 0 ? h("div", { class: "pending-bar" },
h("strong", null, plural(n, "pending change")),
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply")) : null);
}
async function logout() {
+50 -18
View File
@@ -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(/\.$/, "") };
+3 -2
View File
@@ -17,7 +17,8 @@ export async function render(el) {
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "TSIG keys"),
h("div", { class: "muted" }, "Shared secrets that authenticate zone transfers, NOTIFY and other server-to-server traffic. Use them in ACLs and options as 'key NAME'.")),
h("div", { class: "muted" }, "Shared secrets that authenticate zone transfers, NOTIFY and other server-to-server traffic. Use them in ACLs and options as 'key NAME'."),
h("div", { class: "muted small" }, "To let a script, tool or person change only some zones or records through Jarl, create an ", h("a", { href: "#/account" }, "API token"), " restricted to them instead.")),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => keyDialog() }, "New key"))),
h("div", { class: "panel" }, table([
{ label: "Name", render: (k) => h("strong", { class: "mono" }, k.name) },
@@ -52,7 +53,7 @@ async function keyDialog() {
const secret = input({ placeholder: "leave empty to generate a random secret", class: "mono" });
const comment = input();
await modal("New TSIG key", h("div", null, h("div", { class: "form-grid" },
field("Name", name, "Key names are domain names, e.g. xfer-key or secondary.example.com."),
field("Name", name, "Letters, digits, hyphens and dots, e.g. xfer-key or ns2-xfer.adbraun.lol. It's only a label (it doesn't have to exist in DNS), but both servers must use exactly the same name."),
field("Algorithm", alg),
field("Secret", secret, "Base64. Must be the same on both servers."),
field("Comment", comment)), err), [
+21 -9
View File
@@ -1,5 +1,5 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
import { get, change, describeResult, can, draftQuery } from "../api.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
@@ -63,11 +63,13 @@ export async function newZoneDialog(views, zones, settings) {
const cloneSel = select([["", "– don't copy –"]], "");
const comment = input();
const updateClones = () => {
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
// Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
};
// check validates the name as typed and returns the zone name (or null).
// check validates the name as typed and returns the zone name (or null);
// a network that needs several reverse zones gives a list of names.
const check = () => {
const v = name.value.trim();
const msgs = [];
@@ -80,8 +82,16 @@ export async function newZoneDialog(views, zones, settings) {
if (kind === "reverse") {
const r = reverseZoneFor(v);
if (r.error) msgs.push(["err", r.error]);
else zoneName = r.name;
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · answers \"which host has address …?\" for " + r.network)) : null,
else zoneName = r.name || r.names;
const single = /\/(32|128)$/.test(r.network || "");
const what = single ? " · answers \"which host has address " + r.network.replace(/\/\d+$/, "") + "?\" and nothing else in its network"
: " · answers \"which host has address …?\" for " + r.network;
mount(preview,
typeof zoneName === "string" ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, what)) : null,
Array.isArray(zoneName) ? h("div", null,
h("div", null, "Reverse names follow the dots, so " + r.network + " is covered by " + zoneName.length + " zones, created together:"),
h("div", { class: "mono small" }, zoneName.join(", ")),
h("div", { class: "muted small" }, "Answers \"which host has address …?\" for " + r.network + ".")) : null,
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
} else {
const c = checkDomain(v);
@@ -104,9 +114,9 @@ export async function newZoneDialog(views, zones, settings) {
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
}
if (zoneName && kind === "reverse") {
const fq = zoneName + ".";
if (zones.find((z) => z.name === fq && (z.view || "") === (view.value || ""))) {
mount(preview, h("div", { class: "msg err small" }, "the reverse zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "")));
const existing = [zoneName].flat().filter((n) => zones.find((z) => z.name === n + "." && (z.view || "") === (view.value || "")));
if (existing.length) {
mount(preview, h("div", { class: "msg err small" }, "the reverse zone" + (existing.length > 1 ? "s " : " ") + existing.join(", ") + (existing.length > 1 ? " already exist" : " already exists") + (view.value ? " in view " + view.value : "")));
return null;
}
}
@@ -194,7 +204,9 @@ export async function newZoneDialog(views, zones, settings) {
}
if (opts.length) data.options = opts;
try {
const r = await change("POST", "/zones", data);
const r = Array.isArray(zoneName)
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
: await change("POST", "/zones", data);
toast(describeResult(r));
rerender();
return true;
+6
View File
@@ -118,6 +118,12 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
padding: 8px 16px;
}
.pending-bar strong { color: var(--warn); }
/* The review/live switch in the header. */
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
.mode-toggle .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.mode-toggle.live { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
.mode-toggle.live .dot { background: var(--warn); }
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.page-head .grow { flex: 1; min-width: 200px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }