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,38 @@
|
||||
import { h, mount, field, input, toast, errorBox } from "../ui.js";
|
||||
import { api, get } from "../api.js";
|
||||
import { tokenTable, newTokenForm, grantHelp } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
const me = await get("/auth/whoami");
|
||||
const grants = await get("/catalog/grants");
|
||||
const tokensEl = h("div");
|
||||
const drawTokens = async () => {
|
||||
const tokens = await get("/me/tokens");
|
||||
mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens));
|
||||
};
|
||||
const cur = input({ type: "password", autocomplete: "current-password" });
|
||||
const pw1 = input({ type: "password", autocomplete: "new-password" });
|
||||
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
||||
const err = h("div");
|
||||
mount(el,
|
||||
h("h1", null, "Account: " + me.user.username),
|
||||
h("div", { class: "panel" }, h("h2", null, "Permissions"),
|
||||
h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))),
|
||||
h("div", { class: "panel" }, h("h2", null, "Change password"),
|
||||
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)),
|
||||
err,
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
if (pw1.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
|
||||
try {
|
||||
await api("POST", "/me/password", { body: { current_password: cur.value, password: pw1.value } });
|
||||
toast("Password changed; other sessions were logged out");
|
||||
cur.value = pw1.value = pw2.value = "";
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
} }, "Change password"))),
|
||||
h("div", { class: "panel" }, h("h2", null, "My API tokens"),
|
||||
h("p", { class: "muted small" }, "Use a token with the jarl CLI (jarl config set token …) or any HTTP client: Authorization: Bearer <token>."),
|
||||
tokensEl),
|
||||
grantHelp(grants));
|
||||
await drawTokens();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { h, mount, table, field, input, textarea, toast, errorBox, confirmDialog, modal, lines } from "../ui.js";
|
||||
import { get, change, describeResult } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const acls = await get("/acls");
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "ACLs"),
|
||||
h("div", { class: "muted" }, "Named address match lists, used in options such as allow-query, allow-recursion, allow-transfer and in views' match-clients.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => aclDialog(null) }, "New ACL"))),
|
||||
h("div", { class: "msg info small" }, "Built-in ACLs: any, none, localhost (this server's addresses), localnets (networks this server is directly attached to). Elements are checked in order and the first match wins, so put exceptions (!addr) before broader prefixes."),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Name", render: (a) => h("strong", { class: "mono" }, a.name) },
|
||||
{ label: "Elements", render: (a) => a.elements.join("; ") || h("span", { class: "muted" }, "(empty: matches nothing)"), tdClass: "data" },
|
||||
{ label: "Used by", render: (a) => a.used_by.length ? a.used_by.join(", ") : h("span", { class: "muted" }, "unused"), tdClass: "small" },
|
||||
{ label: "Comment", render: (a) => h("span", { class: "muted" }, a.comment || "") },
|
||||
{ label: "", render: (a) => h("span", null,
|
||||
h("button", { class: "small", onclick: () => aclDialog(a) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", disabled: a.used_by.length > 0, title: a.used_by.length ? "Still in use" : "", onclick: () => del(a) }, "Delete")), tdClass: "actions-cell" },
|
||||
], acls, { empty: "No ACLs yet." })));
|
||||
}
|
||||
|
||||
async function aclDialog(a) {
|
||||
const err = h("div");
|
||||
const name = input({ value: a ? a.name : "", placeholder: "homelab", class: "mono" });
|
||||
const elems = textarea(a ? a.elements.join("\n") : "", { class: "mono", rows: 8, placeholder: "192.168.1.0/24\n10.0.0.0/8\n!10.0.0.66\nkey xfer-key\nlocalhost" });
|
||||
const comment = input({ value: a ? a.comment : "" });
|
||||
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Name", name, a ? "Renaming updates every reference automatically." : ""),
|
||||
field("Elements", elems, "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
|
||||
const data = { name: name.value.trim(), elements: lines(elems.value), comment: comment.value };
|
||||
try {
|
||||
const r = a ? await change("PATCH", "/acls/" + a.id, data) : await change("POST", "/acls", data);
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { sticky: true });
|
||||
}
|
||||
|
||||
async function del(a) {
|
||||
if (!(await confirmDialog("Delete ACL", "Delete ACL " + a.name + "?", "Delete", "danger"))) return;
|
||||
try {
|
||||
const r = await change("DELETE", "/acls/" + a.id);
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { h, mount, table, input, select, fmtTime, badge } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const user = input({ placeholder: "user", value: query.get("user") || "", style: { width: "130px" } });
|
||||
const action = select([["", "All actions"], ["auth.", "Logins (auth.*)"], ["changeset.apply", "Applied changes"], ["changeset.", "All changeset activity"], ["user.", "User changes"], ["token.", "Tokens"], ["tsig.", "TSIG keys"], ["system.", "System"], ["import", "Imports"]], query.get("action") || "");
|
||||
const outcome = select([["", "Any outcome"], "success", "failure", "denied"], query.get("outcome") || "");
|
||||
const since = input({ type: "date", value: query.get("since") || "" });
|
||||
const q = input({ type: "search", placeholder: "text in object or message", value: query.get("q") || "" });
|
||||
const list = h("div");
|
||||
let rows = [];
|
||||
const load = async (append) => {
|
||||
const before = append && rows.length ? rows[rows.length - 1].id : undefined;
|
||||
const page = await get("/audit", { user: user.value.trim(), action: action.value, outcome: outcome.value, since: since.value, q: q.value.trim(), limit: 100, before });
|
||||
rows = append ? rows.concat(page) : page;
|
||||
mount(list, table([
|
||||
{ label: "Time", render: (e) => h("span", { class: "nowrap" }, fmtTime(e.time)) },
|
||||
{ label: "User", render: (e) => e.username || h("span", { class: "muted" }, "–") },
|
||||
{ label: "Via", render: (e) => [e.auth, e.token_name ? " (" + e.token_name + ")" : ""].join(""), tdClass: "small" },
|
||||
{ label: "IP", render: (e) => e.ip || "", tdClass: "mono small" },
|
||||
{ label: "Action", render: (e) => h("span", { class: "mono" }, e.action) },
|
||||
{ label: "Object", render: (e) => e.changeset_id ? h("a", { href: "#/history/" + e.changeset_id }, e.object_name || "#" + e.changeset_id) : (e.object_name || ""), tdClass: "small" },
|
||||
{ label: "Outcome", render: (e) => badge(e.outcome, e.outcome === "success" ? "ok" : e.outcome === "denied" ? "warn" : "err") },
|
||||
{ label: "Message", render: (e) => h("span", { class: "small" }, e.message || "") },
|
||||
], rows, { empty: "No events match." }),
|
||||
page.length === 100 ? h("div", { style: { marginTop: "8px" } }, h("button", { onclick: () => load(true) }, "Load older")) : null);
|
||||
};
|
||||
const form = h("form", { class: "actions", onsubmit: (e) => { e.preventDefault(); load(false); } },
|
||||
user, action, outcome, since, q, h("button", { class: "primary", type: "submit" }, "Filter"));
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"),
|
||||
h("div", { class: "muted" }, "Logins, API token use, every staged and applied change, user and token administration. Also written to the system journal."))),
|
||||
h("div", { class: "panel" }, form, h("div", { style: { marginTop: "10px" } }, list)));
|
||||
await load(false);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, checkList, time, plural, badge } from "../ui.js";
|
||||
import { api, get, state, useDraft, refreshDraft } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
await refreshDraft();
|
||||
const open = await get("/changesets", { status: "open", mine: 1, limit: 50 });
|
||||
const d = state.draft;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Pending changes"),
|
||||
h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches named until you apply it; then the whole changeset is checked by Jarl and named-checkconf, written, and loaded together.")))];
|
||||
if (!d || !(d.ops || []).length) {
|
||||
parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit zones, records or options and they will show up here.")));
|
||||
} else {
|
||||
parts.push(await draftPanel(d));
|
||||
}
|
||||
const others = open.filter((c) => !d || c.id !== d.id);
|
||||
if (others.length) {
|
||||
parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"),
|
||||
h("p", { class: "muted small" }, "Created through the API or CLI, or earlier in the web UI. Switch to one to review and apply it here."),
|
||||
table([
|
||||
{ label: "#", render: (c) => String(c.id) },
|
||||
{ label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") },
|
||||
{ label: "Operations", render: (c) => String((c.ops || []).length) },
|
||||
{ label: "Via", render: (c) => c.via },
|
||||
{ label: "Created", render: (c) => time(c.created_at) },
|
||||
{ label: "", render: (c) => h("span", null,
|
||||
h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ",
|
||||
h("button", { class: "small danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return;
|
||||
await api("POST", "/changesets/" + c.id + "/discard");
|
||||
rerender();
|
||||
} }, "Discard")), tdClass: "actions-cell" },
|
||||
], others)));
|
||||
}
|
||||
mount(el, parts);
|
||||
}
|
||||
|
||||
async function draftPanel(d) {
|
||||
const desc = input({ value: d.description || "", placeholder: "Describe this change (shown in the history)", style: { width: "100%" } });
|
||||
const preview = h("div", { class: "muted" }, "Checking…");
|
||||
const result = h("div");
|
||||
let valid = false;
|
||||
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
||||
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
|
||||
const ops = d.ops || [];
|
||||
const opsTable = table([
|
||||
{ label: "#", render: (o) => String(o.seq) },
|
||||
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
|
||||
{ label: "Added", render: (o) => time(o.added_at) },
|
||||
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: async () => {
|
||||
try {
|
||||
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + o.seq);
|
||||
useDraft(cs);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
} }, "Remove"), tdClass: "actions-cell" },
|
||||
], ops);
|
||||
const runPreview = async () => {
|
||||
try {
|
||||
const p = await get("/changesets/" + d.id + "/preview");
|
||||
valid = p.valid && !p.error;
|
||||
applyBtn.disabled = !valid;
|
||||
const drift = (p.drift || []);
|
||||
forceBox.classList.toggle("hidden", drift.length === 0);
|
||||
mount(preview,
|
||||
p.error ? errorBox({ message: p.error.message, details: p.error }) : null,
|
||||
valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null,
|
||||
!p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null,
|
||||
problemsList(p.problems),
|
||||
checkList(p.check),
|
||||
drift.length ? h("div", { class: "msg warn" }, "Files changed outside Jarl: ", drift.map((x) => x.path + " (" + x.reason + ")").join(", ")) : null,
|
||||
(p.warnings || []).length ? h("div", { class: "msg warn" }, h("ul", null, p.warnings.map((w) => h("li", null, w)))) : null,
|
||||
(p.existing_problems || []).filter((x) => x.severity !== "info").length ? h("details", null, h("summary", { class: "muted" }, "Existing problems (not caused by this changeset)"), problemsList(p.existing_problems.filter((x) => x.severity !== "info"))) : null,
|
||||
h("h3", null, "Changes to BIND's files" + ((p.files_changed || []).length ? " (" + plural(p.files_changed.length, "file") + ")" : "")),
|
||||
diffView(p.diff));
|
||||
} catch (e) { mount(preview, errorBox(e)); }
|
||||
};
|
||||
applyBtn.addEventListener("click", async () => {
|
||||
applyBtn.disabled = true;
|
||||
mount(result, h("div", { class: "muted" }, "Applying…"));
|
||||
try {
|
||||
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
|
||||
const force = forceBox.querySelector("input").checked;
|
||||
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } });
|
||||
const res = r.result || {};
|
||||
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||
useDraft(null);
|
||||
location.hash = "#/history/" + d.id;
|
||||
} catch (e) {
|
||||
mount(result, errorBox(e));
|
||||
applyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
const discard = h("button", { class: "danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Discard changes", "Throw away all " + ops.length + " pending changes?", "Discard", "danger"))) return;
|
||||
await api("POST", "/changesets/" + d.id + "/discard");
|
||||
useDraft(null);
|
||||
rerender();
|
||||
} }, "Discard all");
|
||||
const panel = h("div", null,
|
||||
h("div", { class: "panel" },
|
||||
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
||||
desc,
|
||||
h("div", { style: { marginTop: "10px" } }, opsTable),
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
|
||||
result),
|
||||
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
||||
runPreview();
|
||||
return panel;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { h, mount, time, problemsList, badge, plural } from "../ui.js";
|
||||
import { get, state } from "../api.js";
|
||||
|
||||
export async function render(el) {
|
||||
const st = await get("/status");
|
||||
const s = st.engine || {};
|
||||
const n = s.named;
|
||||
const cards = h("div", { class: "cards" },
|
||||
card("named", n ? (n.running ? badge("running", "ok") : badge("unknown", "warn")) : badge(s.named_error ? "unreachable" : "not checked", s.named_error ? "err" : ""),
|
||||
n ? [n.version, h("br"), "last configured " + (n.last_configured || "?")] : (s.named_error || "")),
|
||||
card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"),
|
||||
s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"),
|
||||
card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")),
|
||||
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key") + " · " + plural((s.counts || {}).options || 0, "option")),
|
||||
);
|
||||
const last = s.last_applied;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Dashboard"), h("div", { class: "muted" }, "Signed in as " + state.me.user.username + " · " + (state.me.permissions || []).join(", "))),
|
||||
h("div", { class: "actions" },
|
||||
h("a", { class: "button primary", href: "#/zones?new=1" }, "New zone"),
|
||||
h("a", { class: "button", href: "#/changes" }, "Pending changes" + (s.open_changesets ? " (" + s.open_changesets + " open)" : "")))),
|
||||
cards];
|
||||
if (s.drift && s.drift.length) {
|
||||
parts.push(h("div", { class: "msg warn" },
|
||||
h("strong", null, "Files changed outside Jarl: "),
|
||||
s.drift.map((d) => d.path + " (" + d.reason + ")").join(", "),
|
||||
h("div", { class: "small" }, "The next apply refuses to overwrite them unless forced. An administrator can rewrite them with 'jarld resync' (or System → Resync).")));
|
||||
}
|
||||
parts.push(h("div", { class: "panel" },
|
||||
h("h2", null, "Last change"),
|
||||
last ? h("div", null,
|
||||
h("a", { href: "#/history/" + last.id }, "#" + last.id + " " + (last.description || "")), " ",
|
||||
h("span", { class: "muted" }, "by " + last.username + " via " + last.via + ", "), time(last.applied_at))
|
||||
: h("div", { class: "muted" }, "Nothing applied yet.")));
|
||||
const problems = s.problems || [];
|
||||
const real = problems.filter((p) => p.severity !== "info");
|
||||
parts.push(h("div", { class: "panel" },
|
||||
h("h2", null, "Configuration checks"),
|
||||
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
|
||||
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
|
||||
if (s.paths) {
|
||||
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
|
||||
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
|
||||
h("dt", null, "Jarl's named.conf"), h("dd", { class: "mono" }, s.paths.config_dir + "/named.conf"),
|
||||
h("dt", null, "Zone files"), h("dd", { class: "mono" }, s.paths.zone_dir),
|
||||
h("dt", null, "BIND tools"), h("dd", null, s.bind_version || "–"))));
|
||||
}
|
||||
mount(el, parts);
|
||||
}
|
||||
|
||||
function card(label, value, sub) {
|
||||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural } from "../ui.js";
|
||||
import { api, get, useDraft } from "../api.js";
|
||||
|
||||
export async function render(el, params) {
|
||||
if (params.id) return detail(el, params.id);
|
||||
const list = h("div");
|
||||
let rows = [];
|
||||
const load = async (before) => {
|
||||
const page = await get("/changesets", { status: "applied", limit: 50, before });
|
||||
rows = rows.concat(page);
|
||||
mount(list, table([
|
||||
{ label: "#", render: (c) => h("a", { href: "#/history/" + c.id }, String(c.id)) },
|
||||
{ label: "Applied", render: (c) => time(c.applied_at) },
|
||||
{ label: "By", render: (c) => c.applied_by || c.username },
|
||||
{ label: "Via", render: (c) => badge(c.via) },
|
||||
{ label: "Description", render: (c) => h("a", { href: "#/history/" + c.id }, c.description || "(no description)") },
|
||||
{ label: "Operations", render: (c) => String((c.ops || []).length), tdClass: "right" },
|
||||
], rows, { empty: "Nothing has been applied yet." }),
|
||||
page.length === 50 ? h("div", { style: { marginTop: "8px" } }, h("button", { onclick: () => load(rows[rows.length - 1].id) }, "Load older")) : null);
|
||||
};
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"),
|
||||
h("div", { class: "muted" }, "Every applied changeset, with the exact changes made to BIND's files. Any of them can be reverted."))),
|
||||
h("div", { class: "panel" }, list));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function detail(el, id) {
|
||||
const c = await get("/changesets/" + id);
|
||||
const res = c.result || {};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" },
|
||||
h("h1", null, "Changeset #" + c.id),
|
||||
h("div", { class: "muted" }, c.description || "(no description)")),
|
||||
h("div", { class: "actions" },
|
||||
h("a", { class: "button", href: "#/history" }, "All changes"),
|
||||
c.status === "applied" && c.undoable ? h("button", { onclick: () => revert(c) }, "Revert…") : null)),
|
||||
h("div", { class: "panel" }, h("dl", { class: "kv" },
|
||||
h("dt", null, "Status"), h("dd", null, badge(c.status, c.status === "applied" ? "ok" : "")),
|
||||
h("dt", null, "Created"), h("dd", null, fmtTime(c.created_at) + " by " + c.username + " via " + c.via),
|
||||
c.applied_at ? [h("dt", null, "Applied"), h("dd", null, fmtTime(c.applied_at) + " by " + c.applied_by)] : null,
|
||||
(res.serials || []).length ? [h("dt", null, "Serials"), h("dd", { class: "mono" }, res.serials.map((s) => s.zone.replace(/\.$/, "") + (s.view ? " [" + s.view + "]" : "") + ": " + s.old + " → " + s.new).join(", "))] : null,
|
||||
(res.warnings || []).length ? [h("dt", null, "Warnings"), h("dd", null, res.warnings.join("; "))] : null)),
|
||||
h("div", { class: "panel" }, h("h2", null, plural((c.ops || []).length, "operation")), table([
|
||||
{ label: "#", render: (o) => String(o.seq) },
|
||||
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
|
||||
], c.ops || [])),
|
||||
h("div", { class: "panel" }, h("h2", null, "File changes"), diffView(c.diff)));
|
||||
}
|
||||
|
||||
async function revert(c) {
|
||||
if (!(await confirmDialog("Revert changeset #" + c.id, "This creates a new changeset that undoes these changes. You can review it before applying.", "Create revert", "primary"))) return;
|
||||
try {
|
||||
const rev = await api("POST", "/changesets/" + c.id + "/revert");
|
||||
useDraft(rev);
|
||||
toast("Created changeset #" + rev.id + "; review and apply it");
|
||||
location.hash = "#/changes";
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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); }
|
||||
}
|
||||
@@ -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"))));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { h, mount, table, badge } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const q = query.get("q") || "";
|
||||
const results = q.length >= 2 ? await get("/search", { q }) : [];
|
||||
const link = (r) => {
|
||||
if (r.kind === "zone") return "#/zones/" + r.id;
|
||||
if (r.kind === "record") return "#/zones/" + r.zone_id + "/records?q=" + encodeURIComponent(r.label.split(" ")[0]);
|
||||
if (r.kind === "acl") return "#/acls";
|
||||
return "#/";
|
||||
};
|
||||
mount(el, h("h1", null, "Search: ", h("span", { class: "mono" }, q)),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Kind", render: (r) => badge(r.kind) },
|
||||
{ label: "Match", render: (r) => h("a", { href: link(r) }, r.label), tdClass: "data" },
|
||||
{ label: "Where", render: (r) => h("span", { class: "muted" }, r.detail || "") },
|
||||
], results, { empty: q.length < 2 ? "Type at least two characters." : "Nothing found." })));
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, copyBox, badge } from "../ui.js";
|
||||
import { api, get, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
if (!state.me.admin) {
|
||||
mount(el, h("h1", null, "Users"), h("div", { class: "msg warn" }, "Only administrators can manage users."));
|
||||
return;
|
||||
}
|
||||
const [users, grants] = await Promise.all([get("/users"), get("/catalog/grants")]);
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Users"),
|
||||
h("div", { class: "muted" }, "People log in to the web UI with a password; scripts and the CLI use API tokens. Each token belongs to a user and can be restricted to a subset of that user's permissions.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, grants) }, "New user"))),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (u) => h("strong", null, u.username) },
|
||||
{ label: "Role", render: (u) => [u.admin ? badge("admin", "accent") : null, u.disabled ? badge("disabled", "err") : null, !u.has_password ? badge("tokens only") : null] },
|
||||
{ label: "Permissions", render: (u) => u.admin ? h("span", { class: "muted" }, "everything") : h("div", { class: "grants" }, (u.grant_strings || []).map((g) => h("div", null, g))), tdClass: "small" },
|
||||
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") },
|
||||
{ label: "", render: (u) => h("span", null,
|
||||
h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ",
|
||||
h("button", { class: "small", onclick: () => userDialog(u, grants) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", disabled: u.id === state.me.user.id, onclick: () => del(u) }, "Delete")), tdClass: "actions-cell" },
|
||||
], users)),
|
||||
grantHelp(grants));
|
||||
}
|
||||
|
||||
export function grantHelp(grants) {
|
||||
return h("div", { class: "panel" },
|
||||
h("h2", null, "Permissions"),
|
||||
h("p", { class: "small" }, "A grant is ", h("code", null, grants.syntax), ". Write includes read. Filters are patterns where * matches anything, e.g. ", h("code", null, "names=_acme-challenge.*"), ". Zone names without the trailing dot; zones outside views are in view _default."),
|
||||
h("p", { class: "small" }, "Resources: ", grants.resources.join(", "), ", or * for all. Users management is for administrators only."),
|
||||
h("h3", null, "Templates"),
|
||||
h("div", { class: "grants" }, Object.entries(grants.roles).map(([name, gs]) => h("div", null, h("strong", null, name + ": "), gs.join("; ")))),
|
||||
h("h3", null, "Examples"),
|
||||
h("pre", null, "records:write zones=adbraun.lol names=_acme-challenge.* types=TXT # ACME DNS-01 only\nzones:write zones=*.lab.adbraun.lol # lab subzones\nrecords:write views=internal # internal view only\n*:read # read-only"));
|
||||
}
|
||||
|
||||
async function userDialog(u, grants) {
|
||||
const err = h("div");
|
||||
const name = input({ value: u ? u.username : "", autocomplete: "off" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "leave empty to keep" : "at least 8 characters" });
|
||||
const admin = checkbox("Administrator (can do everything, including managing users)", u ? u.admin : false);
|
||||
const disabled = checkbox("Disabled (can't log in, tokens stop working)", u ? u.disabled : false);
|
||||
const service = checkbox("Service account: no password, API tokens only", u ? !u.has_password : false);
|
||||
const gr = textarea(u ? (u.grant_strings || []).join("\n") : "", { class: "mono", rows: 5, placeholder: "records:write zones=example.com\n*:read" });
|
||||
const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], "");
|
||||
tmpl.addEventListener("change", () => {
|
||||
if (!tmpl.value) return;
|
||||
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
|
||||
tmpl.value = "";
|
||||
});
|
||||
const comment = input({ value: u ? u.comment : "" });
|
||||
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Username", name),
|
||||
field("Password", pw),
|
||||
h("span"), service,
|
||||
h("span"), admin,
|
||||
u ? h("span") : null, u ? disabled : null,
|
||||
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
||||
h("span"), tmpl,
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
||||
const data = { username: name.value.trim(), admin: admin.input.checked, grant_strings: lines(gr.value), comment: comment.value };
|
||||
if (service.input.checked) data.password = "";
|
||||
else if (pw.value) data.password = pw.value;
|
||||
if (u) data.disabled = disabled.input.checked;
|
||||
try {
|
||||
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
||||
else await api("POST", "/users", { body: data });
|
||||
toast(u ? "User updated" : "User created");
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
|
||||
async function del(u) {
|
||||
if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return;
|
||||
try {
|
||||
await api("DELETE", "/users/" + u.id);
|
||||
toast("User deleted");
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// tokensDialog manages a user's tokens (also used by the account page).
|
||||
export async function tokensDialog(u, grants, own) {
|
||||
const box = h("div");
|
||||
const base = own ? "/me/tokens" : "/users/" + u.id + "/tokens";
|
||||
const draw = async () => {
|
||||
const tokens = await get(base);
|
||||
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw));
|
||||
};
|
||||
await draw();
|
||||
await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true });
|
||||
}
|
||||
|
||||
export function tokenTable(tokens, redraw) {
|
||||
return table([
|
||||
{ label: "Name", render: (t) => h("strong", null, t.name) },
|
||||
{ label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" },
|
||||
{ label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") },
|
||||
{ label: "", render: (t) => h("button", { class: "small danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Token revoked");
|
||||
redraw();
|
||||
} }, "Revoke"), tdClass: "actions-cell" },
|
||||
], tokens, { empty: "No tokens." });
|
||||
}
|
||||
|
||||
export function newTokenForm(base, grants, redraw) {
|
||||
const err = h("div");
|
||||
const out = h("div");
|
||||
const name = input({ placeholder: "e.g. certbot on web01" });
|
||||
const exp = select([["", "never expires"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "1 year"]], "90d");
|
||||
const gr = textarea("", { class: "mono", rows: 3, placeholder: "optional restriction, e.g.\nrecords:write zones=example.com names=_acme-challenge.* types=TXT" });
|
||||
const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], "");
|
||||
tmpl.addEventListener("change", () => {
|
||||
if (!tmpl.value) return;
|
||||
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
|
||||
tmpl.value = "";
|
||||
});
|
||||
return h("div", { class: "panel", style: { marginTop: "12px" } },
|
||||
h("h3", null, "New token"),
|
||||
h("div", { class: "form-grid" },
|
||||
field("Name", name),
|
||||
field("Expires", exp),
|
||||
field("Restrict to", gr, "Leave empty for all of the user's permissions. A token can never do more than its user."),
|
||||
h("span"), tmpl),
|
||||
err,
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
|
||||
mount(err); mount(out);
|
||||
const body = { name: name.value.trim(), expires_in: exp.value || undefined };
|
||||
const g = lines(gr.value);
|
||||
if (g.length) body.grant_strings = g;
|
||||
try {
|
||||
const t = await api("POST", base, { body });
|
||||
mount(out, h("div", { class: "msg ok" }, h("p", null, "Token created. Copy it now: it is not shown again."), copyBox(t.token)));
|
||||
name.value = ""; gr.value = "";
|
||||
if (redraw) setTimeout(redraw, 15000);
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
} }, "Create token")),
|
||||
out);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js";
|
||||
import { get, change, describeResult } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
|
||||
const viewless = zones.filter((z) => !z.view_id);
|
||||
const optsEl = h("div");
|
||||
const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null;
|
||||
const move = async (i, d) => {
|
||||
const ids = views.map((v) => v.id);
|
||||
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
||||
try {
|
||||
const r = await change("POST", "/views/order", { ids });
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Views"),
|
||||
h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(null, viewless) }, "New view"))),
|
||||
views.length === 0 ? h("div", { class: "msg info" },
|
||||
"No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null,
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "#", render: (v, i) => String(i + 1) },
|
||||
{ label: "View", render: (v) => h("a", { href: "#/views?view=" + v.id }, h("strong", null, v.name)) },
|
||||
{ label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
||||
{ label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
|
||||
{ label: "Zones", render: (v) => h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, String(v.zone_count)) },
|
||||
{ label: "Comment", render: (v) => h("span", { class: "muted" }, v.comment || "") },
|
||||
{ label: "", render: (v, i) => h("span", null,
|
||||
h("button", { class: "small", disabled: i === 0, title: "Move up", onclick: () => move(i, -1) }, "↑"), " ",
|
||||
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓"), " ",
|
||||
h("button", { class: "small", onclick: () => viewDialog(v, viewless) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", onclick: () => deleteView(v) }, "Delete")), tdClass: "actions-cell" },
|
||||
], views, { empty: "No views." })),
|
||||
selected ? optsEl : (views.length ? h("p", { class: "muted" }, "Select a view to see its options.") : null));
|
||||
if (selected) await optionsEditor(optsEl, { scope: "view", scope_id: selected.id, catalogScope: "view", title: "Options of view " + selected.name,
|
||||
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
|
||||
}
|
||||
|
||||
async function viewDialog(v, viewless) {
|
||||
const err = h("div");
|
||||
const name = input({ value: v ? v.name : "", placeholder: "internal" });
|
||||
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
|
||||
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
||||
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
||||
const comment = input({ value: v ? v.comment : "" });
|
||||
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
||||
const body = h("div", null, h("div", { class: "form-grid" },
|
||||
field("Name", name),
|
||||
field("Match clients", mc, "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
||||
field("Match destinations", md, "Server addresses this view answers on (optional)."),
|
||||
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
||||
field("Comment", comment)),
|
||||
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
||||
err);
|
||||
await modal(v ? "Edit view " + v.name : "New view", body, [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: v ? "Save" : "Create view", kind: "primary", onclick: async () => {
|
||||
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
||||
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
||||
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
||||
try {
|
||||
const r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data);
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
|
||||
async function deleteView(v) {
|
||||
let cascade = false;
|
||||
if (v.zone_count > 0) {
|
||||
const c = checkbox("Also delete its " + v.zone_count + " zone(s) and their records", false);
|
||||
const ok = await modal("Delete view " + v.name, h("div", null, h("p", null, "The view contains zones."), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
|
||||
if (ok !== true) return;
|
||||
cascade = c.input.checked;
|
||||
} else if (!(await confirmDialog("Delete view", "Delete view " + v.name + "?", "Delete", "danger"))) return;
|
||||
try {
|
||||
const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined });
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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");
|
||||
}
|
||||
Reference in new issue
Block a user