Files
abrown38andClaude Opus 5.5 785b0006f9 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>
2026-10-08 08:12:03 +00:00

100 lines
5.0 KiB
JavaScript

import { h, mount, table, field, input, select, toast, errorBox, confirmDialog, modal, copyBox } from "../ui.js";
import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js";
const algorithms = ["hmac-sha256", "hmac-sha512", "hmac-sha384", "hmac-sha224", "hmac-sha1", "hmac-md5"];
// randomSecret returns a base64 secret as long as the algorithm's hash.
function randomSecret(alg) {
const bytes = { "hmac-sha512": 64, "hmac-sha384": 48, "hmac-sha224": 28, "hmac-sha1": 20, "hmac-md5": 16 }[alg] || 32;
const raw = new Uint8Array(bytes);
crypto.getRandomValues(raw);
return btoa(String.fromCharCode(...raw));
}
export async function render(el) {
const keys = await get("/tsig-keys");
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 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) },
{ label: "Algorithm", render: (k) => k.algorithm, tdClass: "mono" },
{ label: "Used by", render: (k) => k.used_by.length ? k.used_by.join(", ") : h("span", { class: "muted" }, "unused"), tdClass: "small" },
{ label: "Comment", render: (k) => h("span", { class: "muted" }, k.comment || "") },
{ label: "", render: (k) => h("span", null,
h("button", { class: "small", onclick: () => reveal(k) }, "Show secret"), " ",
h("button", { class: "small", onclick: () => rotate(k) }, "New secret"), " ",
h("button", { class: "small danger", disabled: k.used_by.length > 0, onclick: () => del(k) }, "Delete")), tdClass: "actions-cell" },
], keys, { empty: "No TSIG keys. The rndc control key normally stays in /etc/bind/rndc.key and is not managed here." })));
}
function snippet(k) {
return 'key "' + k.name + '" {\n\talgorithm ' + k.algorithm + ';\n\tsecret "' + k.secret + '";\n};';
}
function showSecret(k, title) {
return modal(title, h("div", null,
h("p", null, "Secret of ", h("strong", { class: "mono" }, k.name), ":"),
copyBox(k.secret),
h("h3", null, "named.conf for the other server"),
h("pre", null, snippet(k)),
h("h3", null, "Usage"),
h("pre", null, "dig @this-server example.com AXFR -y " + k.algorithm + ":" + k.name + ":" + k.secret)));
}
async function keyDialog() {
const err = h("div");
const name = input({ placeholder: "xfer-key", class: "mono" });
const alg = select(algorithms, "hmac-sha256");
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, "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), [
{ label: "Cancel", value: null },
{ label: "Create key", kind: "primary", onclick: async () => {
const data = { name: name.value.trim(), algorithm: alg.value, comment: comment.value, secret: secret.value.trim() || randomSecret(alg.value) };
try {
const r = await change("POST", "/tsig-keys", data);
toast(describeResult(r));
await showSecret({ name: data.name, algorithm: data.algorithm, secret: data.secret }, r.staged ? "Key staged" : "Key created");
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { sticky: true });
}
async function reveal(k) {
try {
const full = await get("/tsig-keys/" + k.id + "?secret=1");
await showSecret(full, "TSIG key " + k.name);
} catch (e) { toast(e.message, "err", 8000); }
}
async function rotate(k) {
if (!(await confirmDialog("New secret", "Generate a new secret for " + k.name + "? Every server using the key must be updated with the new secret.", "Generate", "danger"))) return;
const secret = randomSecret(k.algorithm);
try {
const r = await change("PATCH", "/tsig-keys/" + k.id, { secret });
toast(describeResult(r));
await showSecret({ ...k, secret }, "New secret for " + k.name);
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function del(k) {
if (!(await confirmDialog("Delete key", "Delete TSIG key " + k.name + "?", "Delete", "danger"))) return;
try {
const r = await change("DELETE", "/tsig-keys/" + k.id);
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}