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
@@ -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() {
|
||||
|
||||
@@ -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(/\.$/, "") };
|
||||
|
||||
@@ -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), [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user