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>
138 lines
8.7 KiB
JavaScript
138 lines
8.7 KiB
JavaScript
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
|
||
import { get, change, describeResult, can, draftQuery } from "../api.js";
|
||
import { rerender } from "../app.js";
|
||
|
||
export async function render(el, params, query) {
|
||
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
|
||
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
||
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
||
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
||
const list = h("div");
|
||
const draw = () => {
|
||
const q = filter.value.trim().toLowerCase();
|
||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||
mount(list, table([
|
||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||
views.length ? { label: "View", render: (z) => z.view || h("span", { class: "muted" }, "–") } : null,
|
||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||
{ label: "Records", render: (z) => z.type === "primary" ? String(z.record_count) : "", class: "right", tdClass: "right" },
|
||
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
||
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
||
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" }));
|
||
};
|
||
filter.addEventListener("input", draw);
|
||
viewSel.addEventListener("change", draw);
|
||
typeSel.addEventListener("change", draw);
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
|
||
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
|
||
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
|
||
h("div", { class: "panel" }, list));
|
||
draw();
|
||
if (query.get("new")) newZoneDialog(views, zones, settings);
|
||
}
|
||
|
||
export async function newZoneDialog(views, zones, settings) {
|
||
const err = h("div");
|
||
const name = input({ placeholder: "example.com or 192.168.1.0/24" });
|
||
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
||
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : "");
|
||
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
|
||
const nsAddr = textarea("", { class: "mono", rows: 2, placeholder: "ns1 192.0.2.1" });
|
||
const contact = input({ value: settings.rname ? emailOf(settings.rname) : "", placeholder: "hostmaster@example.com" });
|
||
const ttl = input({ type: "number", value: settings.default_ttl || 3600, min: 0 });
|
||
const primaries = textarea("", { class: "mono", rows: 2, placeholder: "192.0.2.53\n2001:db8::53 key xfer-key" });
|
||
const forwarders = textarea("", { class: "mono", rows: 2, placeholder: "192.168.1.1\n127.0.0.1 port 8600" });
|
||
const forwardMode = select(["only", "first"], "only");
|
||
const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
|
||
const inView = select(views.map((v) => [v.name, v.name]), "");
|
||
const cloneSel = select([["", "– don't copy –"]], "");
|
||
const comment = input();
|
||
const updateClones = () => {
|
||
const n = name.value.trim().toLowerCase().replace(/\.$/, "") + ".";
|
||
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
|
||
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
|
||
};
|
||
name.addEventListener("input", updateClones);
|
||
const rows = {
|
||
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
||
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
||
field("SOA contact", contact, "Email address of the zone's administrator."),
|
||
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
||
field("Copy records from", cloneSel, "For split-horizon setups: start with the records of this zone in another view.")],
|
||
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
||
stub: [field("Primary servers", primaries, "One per line.")],
|
||
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
||
forward: [field("Forwarders", forwarders, "One per line: address [port N]. Leave empty to disable forwarding for this domain."), field("Forward", forwardMode, "only: never resolve these names directly; first: try the forwarders, then resolve normally.")],
|
||
"static-stub": [field("Server addresses", primaries, "One per line.")],
|
||
hint: [field("File", file, "Root hints file, e.g. /usr/share/dns/root.hints.")],
|
||
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
|
||
};
|
||
const typeRows = h("div", { class: "form-grid" });
|
||
const drawType = () => mount(typeRows, rows[type.value] || []);
|
||
type.addEventListener("change", drawType);
|
||
drawType();
|
||
const body = h("div", null,
|
||
h("div", { class: "form-grid" },
|
||
field("Name", name, "A domain name, or a network such as 192.168.1.0/24 to create its reverse zone."),
|
||
views.length ? field("View", view) : null,
|
||
field("Type", type),
|
||
field("Comment", comment)),
|
||
h("div", { style: { marginTop: "8px" } }, typeRows),
|
||
err);
|
||
await modal("New zone", body, [
|
||
{ label: "Cancel", value: null },
|
||
{ label: "Create zone", kind: "primary", onclick: async () => {
|
||
const t = type.value;
|
||
const data = { name: name.value.trim(), type: t, comment: comment.value || undefined };
|
||
if (view.value) data.view = view.value;
|
||
const opts = [];
|
||
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
||
if (t === "primary") {
|
||
data.nameservers = lines(ns.value);
|
||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||
data.default_ttl = Number(ttl.value) || undefined;
|
||
if (cloneSel.value) { data.clone_from = Number(cloneSel.value); delete data.nameservers; }
|
||
data.records = lines(nsAddr.value).map((l) => {
|
||
const [n, a] = l.split(/\s+/);
|
||
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };
|
||
});
|
||
} else if (t === "secondary" || t === "stub" || t === "mirror") {
|
||
if (lines(primaries.value).length) opts.push({ name: "primaries", value: "{ " + list(primaries) + " }" });
|
||
} else if (t === "static-stub") {
|
||
opts.push({ name: "server-addresses", value: "{ " + list(primaries) + " }" });
|
||
} else if (t === "forward") {
|
||
opts.push({ name: "forwarders", value: "{ " + list(forwarders) + " }" });
|
||
opts.push({ name: "forward", value: forwardMode.value });
|
||
} else if (t === "hint") {
|
||
data.file = file.value.trim();
|
||
} else if (t === "in-view") {
|
||
data.in_view = inView.value;
|
||
}
|
||
if (opts.length) data.options = opts;
|
||
try {
|
||
const r = await change("POST", "/zones", data);
|
||
toast(describeResult(r));
|
||
rerender();
|
||
return true;
|
||
} catch (e) {
|
||
mount(err, errorBox(e));
|
||
return false;
|
||
}
|
||
} },
|
||
], { wide: true, sticky: true });
|
||
}
|
||
|
||
export function emailOf(rname) {
|
||
if (!rname) return "";
|
||
const s = rname.replace(/\.$/, "");
|
||
const m = s.match(/^((?:[^.\\]|\\.)+)\.(.+)$/);
|
||
if (!m) return s;
|
||
return m[1].replace(/\\\./g, ".") + "@" + m[2];
|
||
}
|
||
|
||
export function pendingBadge(kind) {
|
||
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
||
}
|