Files
jarl/internal/web/static/assets/pages/keys.js
T
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:03:13 +00:00

99 lines
4.7 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: "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, "Key names are domain names, e.g. xfer-key or secondary.example.com."),
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); }
}