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:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,290 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView } from "../ui.js";
|
||||
import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
import { emailOf, pendingBadge } from "./zones.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const id = params.id;
|
||||
const zone = await get("/zones/" + id, draftQuery());
|
||||
const tab = params.tab || (zone.type === "primary" ? "records" : "settings");
|
||||
const tabs = [
|
||||
zone.type === "primary" ? ["records", "Records (" + zone.record_count + ")"] : null,
|
||||
["settings", "Settings"],
|
||||
["options", "Options"],
|
||||
zone.type === "primary" ? ["file", "Zone file"] : null,
|
||||
].filter(Boolean);
|
||||
const body = h("div");
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" },
|
||||
h("h1", { class: "mono" }, zone.display_name),
|
||||
h("div", { class: "muted" },
|
||||
badge(zone.type, "accent"), " ",
|
||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||
zone.view ? ["view ", h("a", { href: "#/views" }, zone.view), " · "] : null,
|
||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||
h("div", { class: "actions" },
|
||||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))),
|
||||
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))),
|
||||
body);
|
||||
if (tab === "records") await recordsTab(body, zone, query);
|
||||
else if (tab === "settings") await settingsTab(body, zone);
|
||||
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
||||
else if (tab === "file") await fileTab(body, zone);
|
||||
}
|
||||
|
||||
async function deleteZone(zone) {
|
||||
const msg = zone.type === "primary"
|
||||
? "Delete zone " + zone.display_name + " and its " + zone.record_count + " records? named stops serving it once applied."
|
||||
: "Delete zone " + zone.display_name + "?";
|
||||
if (!(await confirmDialog("Delete zone", msg, "Delete zone", "danger"))) return;
|
||||
try {
|
||||
const r = await change("DELETE", "/zones/" + zone.id);
|
||||
toast(describeResult(r));
|
||||
location.hash = "#/zones";
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// ---- records
|
||||
|
||||
async function recordsTab(el, zone, query) {
|
||||
const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]);
|
||||
const typeInfo = Object.fromEntries(types.map((t) => [t.type, t]));
|
||||
const err = h("div");
|
||||
// Add form
|
||||
const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" });
|
||||
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } });
|
||||
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
|
||||
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
||||
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
|
||||
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
|
||||
const hint = h("div", { class: "hint" });
|
||||
const showHint = () => {
|
||||
const t = typeInfo[fType.value] || {};
|
||||
fData.placeholder = t.example || t.format || "";
|
||||
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
|
||||
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
|
||||
};
|
||||
fType.addEventListener("change", showHint);
|
||||
showHint();
|
||||
const add = async (e) => {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
const body = { name: fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
|
||||
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
|
||||
if (fComment.value.trim()) body.comment = fComment.value.trim();
|
||||
if (fPTR.input.checked) body.ptr = true;
|
||||
try {
|
||||
const r = await change("POST", "/zones/" + zone.id + "/records", body);
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||
fData.value = "";
|
||||
fComment.value = "";
|
||||
await redraw();
|
||||
fData.focus();
|
||||
} catch (ex) {
|
||||
mount(err, errorBox(ex));
|
||||
}
|
||||
};
|
||||
const form = h("form", { class: "inline-form", onsubmit: add },
|
||||
h("div", { class: "f" }, h("label", null, "Name"), fName),
|
||||
h("div", { class: "f" }, h("label", null, "Type"), fType),
|
||||
h("div", { class: "f" }, h("label", null, "TTL"), fTTL),
|
||||
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
|
||||
h("div", { class: "f" }, h("label", null, "Comment"), fComment),
|
||||
h("div", { class: "f" }, h("label", null, " "), fPTR),
|
||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record")));
|
||||
|
||||
// Filters and table
|
||||
const filter = input({ type: "search", placeholder: "Filter by name or data…", value: query.get("q") || "" });
|
||||
const typeFilter = select([["", "All types"], ...[...new Set(records.map((r) => r.type))].sort()], "");
|
||||
const list = h("div");
|
||||
let editing = null;
|
||||
let current = records;
|
||||
const redraw = async () => {
|
||||
current = await get("/zones/" + zone.id + "/records", draftQuery());
|
||||
draw();
|
||||
};
|
||||
const draw = () => {
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value));
|
||||
let prevName = null;
|
||||
mount(list, table([
|
||||
{ label: "Name", render: (r) => r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
|
||||
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
||||
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
||||
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
||||
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
|
||||
], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
||||
if (editing) mountEdit();
|
||||
};
|
||||
const del = async (r) => {
|
||||
const isAddr = r.type === "A" || r.type === "AAAA";
|
||||
let ptr = false;
|
||||
if (isAddr) {
|
||||
const c = checkbox("Also delete the matching PTR record", true);
|
||||
const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
|
||||
if (ok !== true) return;
|
||||
ptr = c.input.checked;
|
||||
} else if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data, "Delete", "danger"))) return;
|
||||
try {
|
||||
const res = await change("DELETE", "/records/" + r.id, undefined, { query: ptr ? { ptr: 1 } : undefined });
|
||||
toast(describeResult(res));
|
||||
await redraw();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const edit = (r) => { editing = r; draw(); };
|
||||
const mountEdit = () => {
|
||||
const r = editing;
|
||||
const eName = input({ value: r.relative_name, class: "mono", style: { width: "100%" } });
|
||||
const eType = select(types.map((t) => t.type), r.type);
|
||||
const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } });
|
||||
const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } });
|
||||
const eComment = input({ value: r.comment || "", style: { width: "100%" } });
|
||||
const ePTR = checkbox("update PTR", false);
|
||||
const eErr = h("div");
|
||||
const save = async () => {
|
||||
const body = {};
|
||||
const name = eName.value.trim() || "@";
|
||||
if (name !== r.relative_name) body.name = name;
|
||||
if (eType.value !== r.type) body.type = eType.value;
|
||||
const shown = r.type === "TXT" && r.text !== undefined ? r.text : r.data;
|
||||
if (eData.value.trim() !== shown || body.type) body.data = eData.value.trim();
|
||||
if (eTTL.value === "" && r.ttl !== undefined && r.ttl !== null) body.ttl = null;
|
||||
else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value);
|
||||
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
|
||||
if (ePTR.input.checked) body.ptr = true;
|
||||
if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.ptr)) { editing = null; draw(); return; }
|
||||
try {
|
||||
const res = await change("PATCH", "/records/" + r.id, body);
|
||||
toast(describeResult(res));
|
||||
(res.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||
editing = null;
|
||||
await redraw();
|
||||
} catch (e) { mount(eErr, errorBox(e)); }
|
||||
};
|
||||
const row = h("div", { class: "panel", style: { background: "var(--accent-soft)" } },
|
||||
h("h3", null, "Edit record #" + r.id),
|
||||
h("div", { class: "inline-form" },
|
||||
h("div", { class: "f" }, h("label", null, "Name"), eName),
|
||||
h("div", { class: "f" }, h("label", null, "Type"), eType),
|
||||
h("div", { class: "f" }, h("label", null, "TTL"), eTTL),
|
||||
h("div", { class: "f grow" }, h("label", null, "Data"), eData),
|
||||
h("div", { class: "f" }, h("label", null, "Comment"), eComment),
|
||||
(r.type === "A" || r.type === "AAAA") ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
|
||||
h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))),
|
||||
h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."),
|
||||
eErr);
|
||||
list.prepend(row);
|
||||
eData.focus();
|
||||
};
|
||||
filter.addEventListener("input", draw);
|
||||
typeFilter.addEventListener("change", draw);
|
||||
mount(el,
|
||||
h("div", { class: "panel" }, form, hint, err),
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter,
|
||||
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute.")),
|
||||
list));
|
||||
draw();
|
||||
fName.focus();
|
||||
}
|
||||
|
||||
// ---- settings
|
||||
|
||||
async function settingsTab(el, zone) {
|
||||
const views = await get("/views");
|
||||
const err = h("div");
|
||||
const rows = [];
|
||||
const s = zone.soa || {};
|
||||
const mname = input({ value: display(s.mname), class: "mono" });
|
||||
const rname = input({ value: zone.rname_email || emailOf(s.rname) });
|
||||
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 minimum = input({ type: "number", value: s.minimum });
|
||||
const ttl = input({ type: "number", value: zone.default_ttl });
|
||||
const policy = select([["", "(server default)"], "date", "increment", "unixtime"], zone.serial_policy || "");
|
||||
const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || ""));
|
||||
const file = input({ value: zone.file || "", class: "mono" });
|
||||
const comment = textarea(zone.comment, { rows: 2 });
|
||||
if (zone.type === "primary") {
|
||||
rows.push(
|
||||
field("Primary name server", mname, "SOA MNAME."),
|
||||
field("Contact", rname, "SOA RNAME, as an email address."),
|
||||
field("Refresh", refresh, "Seconds between secondaries checking for updates."),
|
||||
field("Retry", retry, "Seconds before a failed refresh is retried."),
|
||||
field("Expire", expire, "Seconds after which secondaries stop answering without a successful refresh."),
|
||||
field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."),
|
||||
field("Default TTL", ttl, "TTL for records that don't set their own."),
|
||||
field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes."));
|
||||
}
|
||||
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones)."));
|
||||
if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it."));
|
||||
rows.push(field("Comment", comment));
|
||||
const save = async () => {
|
||||
mount(err);
|
||||
const body = { comment: comment.value };
|
||||
if (zone.type === "primary") {
|
||||
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
|
||||
body.default_ttl = +ttl.value;
|
||||
body.serial_policy = policy.value;
|
||||
}
|
||||
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim();
|
||||
if (views.length) body.view_id = view.value ? Number(view.value) : 0;
|
||||
try {
|
||||
const r = await change("PATCH", "/zones/" + zone.id, body);
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el, h("div", { class: "panel" }, h("div", { class: "form-grid" }, rows), err,
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings"))));
|
||||
}
|
||||
|
||||
// ---- zone file
|
||||
|
||||
async function fileTab(el, zone) {
|
||||
const text = await get("/zones/" + zone.id + "/file", draftQuery());
|
||||
const imp = textarea("", { class: "mono", rows: 10, placeholder: "$TTL 3600\nwww IN A 192.0.2.10\n..." });
|
||||
const mode = select([["merge", "merge: add records that are missing"], ["replace", "replace: make the zone equal to this text"]], "merge");
|
||||
const useSOA = checkbox("Also take SOA timers and $TTL from the text", false);
|
||||
const out = h("div");
|
||||
const run = async (dry) => {
|
||||
mount(out);
|
||||
try {
|
||||
const body = { text: imp.value, mode: mode.value, use_soa: useSOA.input.checked, dry_run: dry };
|
||||
const r = dry ? await get_dry(zone, body) : await change("POST", "/zones/" + zone.id + "/import", body);
|
||||
if (dry) {
|
||||
mount(out, h("h3", null, "Preview"), diffViewOf(r));
|
||||
} else {
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||
rerender();
|
||||
}
|
||||
} catch (e) { mount(out, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "panel" }, h("h2", null, "Generated zone file"), h("p", { class: "muted small" }, state.draft && !state.immediate ? "What Jarl will write for named once the pending changes are applied." : "This is exactly what Jarl writes for named."), h("pre", null, text)),
|
||||
h("div", { class: "panel" }, h("h2", null, "Import records from zone file text"),
|
||||
h("p", { class: "muted small" }, "Paste records in zone file format. Names are relative to " + zone.display_name + ". The SOA record is ignored unless you tick the box."),
|
||||
imp, h("div", { class: "actions", style: { marginTop: "8px" } }, mode, useSOA,
|
||||
h("button", { onclick: () => run(true) }, "Preview"),
|
||||
h("button", { class: "primary", onclick: () => run(false) }, "Import")),
|
||||
out));
|
||||
}
|
||||
|
||||
async function get_dry(zone, body) {
|
||||
return api("POST", "/zones/" + zone.id + "/import", { body: { ...body, dry_run: true } });
|
||||
}
|
||||
|
||||
function diffViewOf(r) {
|
||||
const res = r && r.result;
|
||||
if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import.");
|
||||
return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff));
|
||||
}
|
||||
Reference in new issue
Block a user