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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js";
import { emailOf } from "./zones.js";
const catalogCache = {};
async function catalog(scope) {
if (!catalogCache[scope]) catalogCache[scope] = await get("/catalog/options", { scope });
return catalogCache[scope];
}
// valueWidget returns {el, get()} for editing a value of the given kind.
function valueWidget(stmt, value) {
const kind = stmt ? stmt.kind : "raw";
if (kind === "bool") {
const s = select(["yes", "no"], value || "yes");
return { el: s, get: () => s.value };
}
if (kind === "enum") {
const s = select(stmt.choices, value || stmt.choices[0]);
return { el: s, get: () => s.value };
}
if (kind === "address_match_list") {
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
return { el: t, get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
}
if (kind === "raw") {
const t = textarea(value || "", { class: "mono", rows: 3 });
return { el: t, get: () => t.value.trim() };
}
const i = input({ value: value || "", class: "mono" });
return { el: i, get: () => i.value.trim() };
}
// optionsEditor renders the options of one scope.
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
const [opts, cat] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope)]);
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
const err = h("div");
const listEl = h("div");
let editing = null;
const draw = () => {
mount(listEl, table([
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.value || "(no value)") },
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => del(o) }, "Delete")), tdClass: "actions-cell" },
], opts, { empty: "No options set; BIND's defaults apply." }));
};
const editRow = (o) => {
const w = valueWidget(byName[o.name], o.value);
const e2 = h("div");
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
h("div", { class: "actions", style: { marginTop: "6px" } },
h("button", { class: "primary small", onclick: async () => {
try {
const r = await change("PATCH", "/options/" + o.id, { value: w.get() });
toast(describeResult(r));
editing = null;
rerender();
} catch (ex) { mount(e2, errorBox(ex)); }
} }, "Save"),
h("button", { class: "small", onclick: () => { editing = null; draw(); } }, "Cancel")), e2);
};
const del = async (o) => {
if (!(await confirmDialog("Remove option", "Remove " + o.name + " (" + o.where + ")? BIND's default applies afterwards.", "Remove", "danger"))) return;
try {
const r = await change("DELETE", "/options/" + o.id);
toast(describeResult(r));
rerender();
} catch (ex) { toast(ex.message, "err", 8000); }
};
// Add form
const listId = "opt-" + Math.random().toString(36).slice(2);
const nameIn = input({ list: listId, placeholder: "Start typing an option name…", class: "mono", style: { width: "280px" } });
const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || "")));
const info = h("div", { class: "hint" });
const valueBox = h("div");
let widget = valueWidget(null, "");
const showInfo = () => {
const st = byName[nameIn.value.trim()];
widget = valueWidget(st, "");
mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null);
if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; }
mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar));
};
nameIn.addEventListener("input", showInfo);
showInfo();
const add = async () => {
mount(err);
try {
const r = await change("POST", "/options", { scope, scope_id: scope_id || undefined, name: nameIn.value.trim(), value: widget.get() });
toast(describeResult(r));
rerender();
} catch (ex) { mount(err, errorBox(ex)); }
};
draw();
mount(el, h("div", { class: "panel" },
h("h2", null, title),
intro ? h("p", { class: "muted small" }, intro) : null,
listEl,
h("h3", null, "Add an option"),
h("div", { class: "form-grid" },
h("label", null, "Name"), h("div", null, nameIn, dl, info),
h("label", null, "Value"), valueBox),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: add }, "Add option"))));
}
export async function render(el) {
const settings = await get("/settings");
const settingsEl = h("div");
const serverEl = h("div");
const topEl = h("div");
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"),
h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))),
settingsEl, serverEl, topEl);
settingsForm(settingsEl, settings);
await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." });
await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements",
intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." });
}
function settingsForm(el, s) {
const ns = textarea((s.nameservers || []).join("\n"), { class: "mono", rows: 2 });
const rname = input({ value: s.rname ? emailOf(s.rname) : "", placeholder: "hostmaster@example.com" });
const ttl = input({ type: "number", value: s.default_ttl });
const refresh = input({ type: "number", value: s.refresh });
const retry = input({ type: "number", value: s.retry });
const expire = input({ type: "number", value: s.expire });
const neg = input({ type: "number", value: s.negative_ttl });
const policy = select(["date", "increment", "unixtime"], s.serial_policy);
const err = h("div");
mount(el, h("div", { class: "panel" },
h("h2", null, "Defaults for new zones"),
h("div", { class: "form-grid" },
field("Name servers", ns, "One per line; used as NS records for new primary zones."),
field("SOA contact", rname, "Email address (or mailbox name) for new zones."),
field("Default TTL", ttl),
field("Refresh", refresh), field("Retry", retry), field("Expire", expire),
field("Negative TTL", neg, "SOA minimum."),
field("Serial policy", policy, "date: YYYYMMDDnn, increment: +1, unixtime: seconds since 1970.")),
err,
h("div", { class: "actions", style: { marginTop: "10px" } }, h("button", { class: "primary", onclick: async () => {
mount(err);
try {
const r = await change("PATCH", "/settings", { nameservers: lines(ns.value), rname: rname.value.trim(), default_ttl: +ttl.value, refresh: +refresh.value, retry: +retry.value, expire: +expire.value, negative_ttl: +neg.value, serial_policy: policy.value });
toast(describeResult(r));
} catch (e) { mount(err, errorBox(e)); }
} }, "Save defaults"))));
}