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>
99 lines
4.7 KiB
JavaScript
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); }
|
|
}
|