- 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>
100 lines
5.0 KiB
JavaScript
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); }
|
|
}
|