Jarl 0.1.0: manage BIND through a web UI, REST API and CLI

Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
// API client and shared application state.
export const state = {
me: null, // whoami response
csrf: "",
draft: null, // the open changeset edits are staged into
immediate: false, // apply edits immediately instead of staging them
rrtypes: null,
listeners: new Set(),
};
export class APIError extends Error {
constructor(status, body) {
const e = (body && body.error) || {};
super(e.message || ("HTTP " + status));
this.status = status;
this.code = e.code || "";
this.details = e;
this.result = e.result || null;
}
}
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// api calls the REST API. opts: {body, stage (bool), query}.
export async function api(method, path, opts = {}) {
const headers = { "Accept": "application/json" };
let body;
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
let url = "/api/v1" + path;
if (opts.query) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage);
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
if (!resp.ok) {
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
throw new APIError(resp.status, data);
}
return data;
}
export const get = (path, query) => api("GET", path, { query });
// draftQuery adds the draft changeset to a query, so reads show the
// configuration as it will be once the pending changes are applied.
export function draftQuery(query = {}) {
if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id };
return query;
}
// ---- drafts
const draftKey = () => "jarl.draft." + (state.me ? state.me.user.id : "");
const immediateKey = () => "jarl.immediate." + (state.me ? state.me.user.id : "");
export function onChange(fn) { state.listeners.add(fn); return () => state.listeners.delete(fn); }
export function notify() { for (const fn of state.listeners) fn(); }
// loadDraft restores the user's draft changeset if it is still open.
export async function loadDraft() {
state.immediate = localStorage.getItem(immediateKey()) === "1";
const id = localStorage.getItem(draftKey());
state.draft = null;
if (id) {
try {
const cs = await get("/changesets/" + id);
if (cs.status === "open") state.draft = cs;
} catch { /* gone */ }
}
if (!state.draft) {
// Pick up the newest open changeset made in the web UI.
try {
const list = await get("/changesets", { status: "open", mine: 1, limit: 20 });
const web = list.find((c) => c.via === "web");
if (web) state.draft = web;
} catch { /* ignore */ }
}
if (state.draft) localStorage.setItem(draftKey(), state.draft.id);
notify();
}
export function setImmediate(on) {
state.immediate = on;
localStorage.setItem(immediateKey(), on ? "1" : "0");
notify();
}
export async function refreshDraft() {
if (!state.draft) return;
try {
const cs = await get("/changesets/" + state.draft.id);
state.draft = cs.status === "open" ? cs : null;
} catch { state.draft = null; }
if (!state.draft) localStorage.removeItem(draftKey());
notify();
}
export function useDraft(cs) {
state.draft = cs;
if (cs) localStorage.setItem(draftKey(), cs.id); else localStorage.removeItem(draftKey());
notify();
}
async function ensureDraft() {
if (state.draft) return state.draft;
const cs = await api("POST", "/changesets", { body: { description: "" } });
useDraft(cs);
return cs;
}
// change sends a configuration change: staged into the draft changeset,
// or applied at once when immediate mode is on (or opts.immediate).
// It returns the API response.
export async function change(method, path, body, opts = {}) {
if (state.immediate || opts.immediate) {
const r = await api(method, path, { body, query: opts.query });
notify();
return r;
}
const cs = await ensureDraft();
try {
const r = await api(method, path, { body, stage: cs.id, query: opts.query });
if (r && r.changeset) state.draft = r.changeset;
notify();
return r;
} catch (e) {
if (e.status === 409 && /is (applied|discarded)/.test(e.message)) {
useDraft(null);
return change(method, path, body, opts);
}
throw e;
}
}
// changeMany submits several requests ({op, id, data}) together.
export async function changeMany(ops, description, opts = {}) {
if (state.immediate || opts.immediate) {
const r = await api("POST", "/apply", { body: { ops, description } });
notify();
return r;
}
const cs = await ensureDraft();
const r = await api("POST", "/changesets/" + cs.id + "/ops", { body: { ops } });
if (r && r.changeset) state.draft = r.changeset;
notify();
return r;
}
// describeResult turns a mutation response into a short message.
export function describeResult(r) {
if (!r) return "Done";
if (r.staged) {
const n = (r.added || []).length;
return "Staged " + (n === 1 ? "1 change" : n + " changes") + " — review and apply under Changes";
}
if (r.applied) return "Applied (changeset #" + r.changeset_id + ")";
if (r.dry_run) return "Dry run OK";
return "Done";
}
export function can(resource, write) {
const me = state.me;
if (!me) return false;
if (me.admin) return true;
return (me.user.grants || []).some((g) =>
(g.resource === "*" || g.resource === resource) && (!write || g.access === "write"));
}
export async function rrtypes() {
if (!state.rrtypes) state.rrtypes = await get("/catalog/rrtypes");
return state.rrtypes;
}
+187
View File
@@ -0,0 +1,187 @@
// Jarl web UI: router, layout and login.
import { h, mount, toast, errorBox, plural } from "./ui.js";
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js";
import * as dashboard from "./pages/dashboard.js";
import * as zones from "./pages/zones.js";
import * as zone from "./pages/zone.js";
import * as views from "./pages/views.js";
import * as acls from "./pages/acls.js";
import * as keys from "./pages/keys.js";
import * as options from "./pages/options.js";
import * as changes from "./pages/changes.js";
import * as history from "./pages/history.js";
import * as audit from "./pages/audit.js";
import * as users from "./pages/users.js";
import * as account from "./pages/account.js";
import * as search from "./pages/search.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
{ pattern: /^zones$/, page: zones, nav: "zones" },
{ pattern: /^zones\/(\d+)(?:\/(\w+))?$/, page: zone, nav: "zones", params: ["id", "tab"] },
{ pattern: /^views$/, page: views, nav: "views" },
{ pattern: /^acls$/, page: acls, nav: "acls" },
{ pattern: /^keys$/, page: keys, nav: "keys" },
{ pattern: /^options$/, page: options, nav: "options" },
{ pattern: /^changes$/, page: changes, nav: "changes" },
{ pattern: /^history$/, page: history, nav: "history" },
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
{ pattern: /^audit$/, page: audit, nav: "audit" },
{ pattern: /^users$/, page: users, nav: "users" },
{ pattern: /^account$/, page: account, nav: "account" },
{ pattern: /^search$/, page: search, nav: "" },
];
const navItems = [
["dashboard", "#/", "Dashboard"],
["zones", "#/zones", "Zones"],
["views", "#/views", "Views"],
["acls", "#/acls", "ACLs"],
["keys", "#/keys", "TSIG keys"],
["options", "#/options", "Server options"],
["changes", "#/changes", "Changes"],
["history", "#/history", "History"],
["audit", "#/audit", "Audit log"],
];
const app = document.getElementById("app");
let mainEl, navEl, barEl, current = { key: "" };
function parseHash() {
const raw = location.hash.replace(/^#\/?/, "");
const [path, qs] = raw.split("?");
return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") };
}
function renderLogin(message) {
const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true });
const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true });
const err = h("div");
const form = h("form", { onsubmit: async (e) => {
e.preventDefault();
mount(err);
try {
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
state.me = me;
state.csrf = me.csrf_token;
await start();
} catch (ex) {
mount(err, errorBox(ex));
pass.select();
}
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
mount(app, h("div", { class: "login panel" },
h("h1", null, "Jarl"),
h("div", { class: "tag" }, "Rule your DNS holds"),
message ? h("div", { class: "msg warn" }, message) : null,
form));
user.focus();
}
function layout() {
navEl = h("nav", { class: "main" });
barEl = h("div");
mainEl = h("main");
const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" });
const me = state.me;
const header = h("header", { class: "top" },
h("a", { class: "brand", href: "#/" }, "Jarl", h("span", null, me.version)),
navEl,
h("div", { class: "spacer" }),
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { class: "small", onclick: logout }, "Log out"));
mount(app, header, barEl, mainEl);
renderNav();
renderBar();
}
function renderNav() {
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
}
function renderBar() {
if (!barEl) return;
const d = state.draft;
const n = d ? (d.ops || []).length : 0;
const mode = h("label", { class: "check small", title: "When on, every edit is applied to named right away instead of being collected for review." },
h("input", { type: "checkbox", checked: state.immediate, onchange: (e) => setImmediate(e.target.checked) }), "Apply edits immediately");
if (n > 0) {
mount(barEl, h("div", { class: "pending-bar" },
h("strong", null, plural(n, "pending change")),
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply"),
h("div", { class: "spacer", style: { flex: 1 } }),
mode));
} else {
mount(barEl, h("div", { class: "pending-bar", style: { background: "var(--panel)" } },
h("span", { class: "muted small" }, state.immediate ? "Edits are applied to named immediately." : "Edits are collected as pending changes; review and apply them under Changes."),
h("div", { style: { flex: 1 } }),
mode));
}
}
async function logout() {
try { await api("POST", "/auth/logout"); } catch { /* ignore */ }
state.me = null;
state.csrf = "";
renderLogin("You have logged out.");
}
async function route() {
if (!state.me) return;
const { path, query } = parseHash();
for (const r of routes) {
const m = path.match(r.pattern);
if (!m) continue;
const params = {};
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
current = { key: path, nav: r.nav, page: r.page, params, query };
renderNav();
mount(mainEl, h("div", { class: "muted" }, "Loading…"));
try {
await r.page.render(mainEl, params, query);
} catch (e) {
mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
}
return;
}
mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard")));
}
// rerender redraws the current page (after changes).
export function rerender() {
if (current.page) route();
}
async function start() {
layout();
await loadDraft();
onChange(renderBar);
window.addEventListener("hashchange", route);
await route();
}
setUnauthorizedHandler(() => {
if (state.me) {
state.me = null;
renderLogin("Your session has expired. Please log in again.");
}
});
(async function boot() {
try {
const me = await get("/auth/whoami");
state.me = me;
state.csrf = me.csrf_token || "";
await start();
} catch (e) {
if (e.status === 401) renderLogin();
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e)));
}
})();
window.addEventListener("unhandledrejection", (e) => {
toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err");
});
@@ -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();
}
+54
View File
@@ -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); }
}
+34
View File
@@ -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);
}
+112
View File
@@ -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); }
}
+98
View File
@@ -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); }
}
+155
View File
@@ -0,0 +1,155 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js";
import { emailOf } from "./zones.js";
const catalogCache = {};
async function catalog(scope) {
if (!catalogCache[scope]) catalogCache[scope] = await get("/catalog/options", { scope });
return catalogCache[scope];
}
// valueWidget returns {el, get()} for editing a value of the given kind.
function valueWidget(stmt, value) {
const kind = stmt ? stmt.kind : "raw";
if (kind === "bool") {
const s = select(["yes", "no"], value || "yes");
return { el: s, get: () => s.value };
}
if (kind === "enum") {
const s = select(stmt.choices, value || stmt.choices[0]);
return { el: s, get: () => s.value };
}
if (kind === "address_match_list") {
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
return { el: t, get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
}
if (kind === "raw") {
const t = textarea(value || "", { class: "mono", rows: 3 });
return { el: t, get: () => t.value.trim() };
}
const i = input({ value: value || "", class: "mono" });
return { el: i, get: () => i.value.trim() };
}
// optionsEditor renders the options of one scope.
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
const [opts, cat] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope)]);
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
const err = h("div");
const listEl = h("div");
let editing = null;
const draw = () => {
mount(listEl, table([
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.value || "(no value)") },
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => del(o) }, "Delete")), tdClass: "actions-cell" },
], opts, { empty: "No options set; BIND's defaults apply." }));
};
const editRow = (o) => {
const w = valueWidget(byName[o.name], o.value);
const e2 = h("div");
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
h("div", { class: "actions", style: { marginTop: "6px" } },
h("button", { class: "primary small", onclick: async () => {
try {
const r = await change("PATCH", "/options/" + o.id, { value: w.get() });
toast(describeResult(r));
editing = null;
rerender();
} catch (ex) { mount(e2, errorBox(ex)); }
} }, "Save"),
h("button", { class: "small", onclick: () => { editing = null; draw(); } }, "Cancel")), e2);
};
const del = async (o) => {
if (!(await confirmDialog("Remove option", "Remove " + o.name + " (" + o.where + ")? BIND's default applies afterwards.", "Remove", "danger"))) return;
try {
const r = await change("DELETE", "/options/" + o.id);
toast(describeResult(r));
rerender();
} catch (ex) { toast(ex.message, "err", 8000); }
};
// Add form
const listId = "opt-" + Math.random().toString(36).slice(2);
const nameIn = input({ list: listId, placeholder: "Start typing an option name…", class: "mono", style: { width: "280px" } });
const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || "")));
const info = h("div", { class: "hint" });
const valueBox = h("div");
let widget = valueWidget(null, "");
const showInfo = () => {
const st = byName[nameIn.value.trim()];
widget = valueWidget(st, "");
mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null);
if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; }
mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar));
};
nameIn.addEventListener("input", showInfo);
showInfo();
const add = async () => {
mount(err);
try {
const r = await change("POST", "/options", { scope, scope_id: scope_id || undefined, name: nameIn.value.trim(), value: widget.get() });
toast(describeResult(r));
rerender();
} catch (ex) { mount(err, errorBox(ex)); }
};
draw();
mount(el, h("div", { class: "panel" },
h("h2", null, title),
intro ? h("p", { class: "muted small" }, intro) : null,
listEl,
h("h3", null, "Add an option"),
h("div", { class: "form-grid" },
h("label", null, "Name"), h("div", null, nameIn, dl, info),
h("label", null, "Value"), valueBox),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: add }, "Add option"))));
}
export async function render(el) {
const settings = await get("/settings");
const settingsEl = h("div");
const serverEl = h("div");
const topEl = h("div");
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"),
h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))),
settingsEl, serverEl, topEl);
settingsForm(settingsEl, settings);
await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." });
await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements",
intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." });
}
function settingsForm(el, s) {
const ns = textarea((s.nameservers || []).join("\n"), { class: "mono", rows: 2 });
const rname = input({ value: s.rname ? emailOf(s.rname) : "", placeholder: "hostmaster@example.com" });
const ttl = input({ type: "number", value: s.default_ttl });
const refresh = input({ type: "number", value: s.refresh });
const retry = input({ type: "number", value: s.retry });
const expire = input({ type: "number", value: s.expire });
const neg = input({ type: "number", value: s.negative_ttl });
const policy = select(["date", "increment", "unixtime"], s.serial_policy);
const err = h("div");
mount(el, h("div", { class: "panel" },
h("h2", null, "Defaults for new zones"),
h("div", { class: "form-grid" },
field("Name servers", ns, "One per line; used as NS records for new primary zones."),
field("SOA contact", rname, "Email address (or mailbox name) for new zones."),
field("Default TTL", ttl),
field("Refresh", refresh), field("Retry", retry), field("Expire", expire),
field("Negative TTL", neg, "SOA minimum."),
field("Serial policy", policy, "date: YYYYMMDDnn, increment: +1, unixtime: seconds since 1970.")),
err,
h("div", { class: "actions", style: { marginTop: "10px" } }, h("button", { class: "primary", onclick: async () => {
mount(err);
try {
const r = await change("PATCH", "/settings", { nameservers: lines(ns.value), rname: rname.value.trim(), default_ttl: +ttl.value, refresh: +refresh.value, retry: +retry.value, expire: +expire.value, negative_ttl: +neg.value, serial_policy: policy.value });
toast(describeResult(r));
} catch (e) { mount(err, errorBox(e)); }
} }, "Save defaults"))));
}
@@ -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." })));
}
+151
View File
@@ -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);
}
+90
View File
@@ -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); }
}
+290
View File
@@ -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));
}
+137
View File
@@ -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");
}
+234
View File
@@ -0,0 +1,234 @@
/* Jarl web UI. Plain CSS, light and dark themes. */
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1d2330;
--muted: #5f6b7d;
--border: #d9dee6;
--accent: #2f5fb3;
--accent-text: #ffffff;
--accent-soft: #e5edfa;
--ok: #1e7a46;
--ok-soft: #e3f4ea;
--warn: #8a5a00;
--warn-soft: #fdf2d9;
--err: #b3261e;
--err-soft: #fbe7e5;
--info-soft: #eef1f6;
--code-bg: #f0f2f5;
--add: #e6f6ea;
--add-text: #135c2c;
--del: #fde8e7;
--del-text: #8c1d18;
--hunk: #e8eefb;
--shadow: 0 1px 2px rgba(20, 30, 50, 0.08);
--radius: 6px;
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14171c;
--panel: #1c2027;
--text: #e3e7ee;
--muted: #9aa5b5;
--border: #2f3540;
--accent: #6d9be8;
--accent-text: #0f1520;
--accent-soft: #23314a;
--ok: #59c287;
--ok-soft: #173326;
--warn: #e5b450;
--warn-soft: #3a2f15;
--err: #f08077;
--err-soft: #3c1f1d;
--info-soft: #232833;
--code-bg: #242933;
--add: #183a25;
--add-text: #9fe3b5;
--del: #43201e;
--del-text: #f7b2ac;
--hunk: #23314a;
--shadow: none;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540;
--accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326;
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
--shadow: none; color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, .mono, textarea.mono, input.mono { font-family: var(--mono); font-size: 13px; }
pre { background: var(--code-bg); padding: 10px 12px; border-radius: var(--radius); overflow: auto; margin: 0; white-space: pre; }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 18px 0 8px; }
h3 { font-size: 14px; margin: 14px 0 6px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.nowrap { white-space: nowrap; }
.right { text-align: right; }
.hidden { display: none !important; }
/* Layout */
header.top {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 8px 16px; position: sticky; top: 0; z-index: 10;
}
header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); }
header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; }
nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
nav.main a:hover { background: var(--info-soft); text-decoration: none; }
nav.main a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
header.top .spacer { flex: 1; }
header.top form.search input { width: 220px; }
header.top .user { color: var(--muted); }
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
.pending-bar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--border);
padding: 8px 16px;
}
.pending-bar strong { color: var(--warn); }
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.page-head .grow { flex: 1; min-width: 200px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* Panels and cards */
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-bottom: 14px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
.card .sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; background: var(--panel); }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
tr:hover td { background: var(--info-soft); }
td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; }
td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
td.name.repeat { color: var(--muted); }
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
tr.editing td { background: var(--accent-soft); }
tr.pending-add td { background: var(--add); }
tr.pending-change td { background: var(--warn-soft); }
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
.empty { color: var(--muted); padding: 18px; text-align: center; }
/* Forms */
input, select, textarea, button {
font: inherit; color: var(--text);
}
input[type=text], input[type=password], input[type=number], input[type=search], input:not([type]), select, textarea {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; min-width: 0;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
textarea { width: 100%; min-height: 80px; resize: vertical; }
.form-grid { display: grid; grid-template-columns: minmax(140px, 200px) 1fr; gap: 8px 14px; align-items: start; }
.form-grid > label { padding-top: 6px; color: var(--muted); }
.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; }
.form-grid input, .form-grid select { width: 100%; max-width: 560px; }
.inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
.inline-form .f label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }
.inline-form .grow { flex: 1; min-width: 220px; }
.inline-form .grow input { width: 100%; }
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
button, .button {
display: inline-flex; align-items: center; gap: 6px;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 5px 12px; cursor: pointer; white-space: nowrap; color: var(--text);
}
button:hover, .button:hover { background: var(--info-soft); text-decoration: none; }
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
button.primary:hover { filter: brightness(1.08); background: var(--accent); }
button.danger { color: var(--err); border-color: var(--err); }
button.danger:hover { background: var(--err-soft); }
button.small, .button.small { padding: 2px 8px; font-size: 12px; }
button.link { border: none; background: none; color: var(--accent); padding: 0 4px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Badges and messages */
.badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; background: var(--info-soft); color: var(--muted); white-space: nowrap; }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.err { background: var(--err-soft); color: var(--err); }
.badge.accent { background: var(--accent-soft); color: var(--accent); }
.msg { border-radius: var(--radius); padding: 8px 12px; margin: 8px 0; border: 1px solid transparent; }
.msg.err { background: var(--err-soft); color: var(--err); border-color: var(--err); }
.msg.warn { background: var(--warn-soft); color: var(--warn); }
.msg.ok { background: var(--ok-soft); color: var(--ok); }
.msg.info { background: var(--info-soft); }
.msg ul { margin: 4px 0 0; padding-left: 18px; }
.problems { list-style: none; padding: 0; margin: 6px 0; }
.problems li { padding: 4px 8px; border-left: 3px solid var(--border); margin-bottom: 3px; background: var(--info-soft); border-radius: 0 var(--radius) var(--radius) 0; }
.problems li.error { border-color: var(--err); background: var(--err-soft); }
.problems li.warning { border-color: var(--warn); background: var(--warn-soft); }
.problems li .where { color: var(--muted); font-size: 12px; margin-left: 6px; }
/* Diff */
.diff { background: var(--code-bg); border-radius: var(--radius); overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.4; }
.diff div { white-space: pre; padding: 0 10px; }
.diff .add { background: var(--add); color: var(--add-text); }
.diff .del { background: var(--del); color: var(--del-text); }
.diff .hunk { background: var(--hunk); color: var(--accent); }
.diff .file { font-weight: 700; padding-top: 6px; }
/* Tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 12px; flex-wrap: wrap; }
.tabs a { padding: 7px 12px; border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; color: var(--muted); }
.tabs a.active { background: var(--panel); border-color: var(--border); color: var(--text); font-weight: 600; margin-bottom: -1px; }
/* Modal */
.modal-back { position: fixed; inset: 0; background: rgba(10, 15, 25, 0.45); display: flex; align-items: flex-start; justify-content: center; padding: 60px 16px; z-index: 50; overflow: auto; }
.modal { background: var(--panel); border-radius: 8px; border: 1px solid var(--border); width: min(760px, 100%); padding: 18px 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.25); }
.modal.wide { width: min(1100px, 100%); }
.modal h2 { margin-top: 0; }
.modal .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* Toasts */
#toasts { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; max-width: min(480px, calc(100% - 32px)); }
.toast { background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent); padding: 10px 14px; border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
.toast.ok { border-left-color: var(--ok); }
.toast.err { border-left-color: var(--err); }
.toast.warn { border-left-color: var(--warn); }
/* Login */
.login { max-width: 360px; margin: 10vh auto; }
.login h1 { font-size: 26px; text-align: center; margin-bottom: 4px; }
.login .tag { text-align: center; color: var(--muted); margin-bottom: 18px; }
.login form { display: flex; flex-direction: column; gap: 10px; }
.login input { width: 100%; padding: 8px 10px; }
.login button { justify-content: center; padding: 8px; }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; }
.grants { font-family: var(--mono); font-size: 12px; }
.copy-box { display: flex; gap: 6px; align-items: center; }
.copy-box input { flex: 1; font-family: var(--mono); }
.sortable-row { display: flex; align-items: center; gap: 8px; }
@media (max-width: 700px) {
.form-grid { grid-template-columns: 1fr; }
.form-grid .help { grid-column: 1; }
header.top form.search input { width: 140px; }
}
+242
View File
@@ -0,0 +1,242 @@
// Small DOM helpers and widgets used by every page.
// h creates an element. attrs may contain class, style, on<event>
// handlers, dataset, value/checked/disabled and any other attribute.
export function h(tag, attrs, ...children) {
const el = document.createElement(tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
if (v === undefined || v === null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
else if (k === "dataset") Object.assign(el.dataset, v);
else if (k === "value") el.value = v;
else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v;
else el.setAttribute(k, v === true ? "" : v);
}
}
append(el, children);
return el;
}
function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el) {
while (el.firstChild) el.removeChild(el.firstChild);
return el;
}
export function mount(el, ...children) {
clear(el);
append(el, children);
return el;
}
// ---- formatting
export function fmtTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
export function relTime(iso) {
if (!iso) return "";
const s = (Date.now() - new Date(iso).getTime()) / 1000;
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + " min ago";
if (s < 86400) return Math.floor(s / 3600) + " h ago";
if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago";
return fmtTime(iso);
}
export function time(iso) {
return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso));
}
export function display(name) {
if (!name || name === ".") return ".";
return name.endsWith(".") ? name.slice(0, -1) : name;
}
export function plural(n, word, words) {
return n + " " + (n === 1 ? word : (words || word + "s"));
}
export function badge(text, kind) {
return h("span", { class: "badge" + (kind ? " " + kind : "") }, text);
}
// ---- toasts
export function toast(message, kind = "ok", ms = 3500) {
const box = document.getElementById("toasts");
const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message);
// A repeated message replaces the earlier one instead of stacking.
for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove();
box.append(t);
while (box.children.length > 3) box.firstChild.remove();
setTimeout(() => t.remove(), ms);
}
// ---- modal dialogs
// modal shows a dialog. buttons: [{label, kind, value, onclick}].
// onclick may return false to keep the dialog open, or a promise.
export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) {
return new Promise((resolve) => {
const back = h("div", { class: "modal-back" });
const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
const onKey = (e) => { if (e.key === "Escape") close(null); };
document.addEventListener("keydown", onKey);
const btns = buttons.map((b) =>
h("button", {
class: b.kind || "", type: "button",
onclick: async (e) => {
if (b.onclick) {
e.target.disabled = true;
try {
const r = await b.onclick();
if (r === false) return;
} finally { e.target.disabled = false; }
}
close(b.value === undefined ? b.label : b.value);
},
}, b.label));
const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns));
back.append(dialog);
back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); });
document.body.append(back);
const first = dialog.querySelector("input, select, textarea");
if (first) first.focus();
});
}
export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") {
const r = await modal(title, h("p", null, message), [
{ label: "Cancel", value: false },
{ label: okLabel, kind, value: true },
]);
return r === true;
}
// ---- forms
// field returns a label + control pair for .form-grid layouts.
export function field(label, control, help) {
const id = control.id || ("f" + Math.random().toString(36).slice(2, 9));
control.id = id;
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
}
export function input(attrs = {}) {
return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs });
}
export function select(options, value, attrs = {}) {
const s = h("select", attrs);
for (const o of options) {
const [v, label] = Array.isArray(o) ? o : [o, o];
s.append(h("option", { value: v, selected: String(v) === String(value) }, label));
}
return s;
}
export function textarea(value, attrs = {}) {
return h("textarea", { spellcheck: "false", ...attrs, value: value || "" });
}
export function checkbox(label, checked, attrs = {}) {
const c = h("input", { type: "checkbox", checked, ...attrs });
const l = h("label", { class: "check" }, c, label);
l.input = c;
return l;
}
// lines splits a textarea's value into non-empty trimmed lines.
export function lines(text) {
return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== "");
}
// ---- tables
// table renders rows with columns [{label, render(row) => node|string, class}].
export function table(columns, rows, opts = {}) {
if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet.");
const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label))));
const tbody = h("tbody");
rows.forEach((row, i) => {
const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" });
for (const c of columns) {
const v = c.render(row, i);
tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v));
}
tbody.append(tr);
});
return h("div", { class: "table-wrap" }, h("table", null, thead, tbody));
}
// ---- problems, diffs, errors
export function problemsList(problems) {
if (!problems || !problems.length) return null;
return h("ul", { class: "problems" }, problems.map((p) => {
const where = [p.view && "view " + p.view, p.zone && "zone " + display(p.zone)].filter(Boolean).join(", ");
return h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, where ? h("span", { class: "where" }, where) : null);
}));
}
export function checkList(msgs) {
const errs = (msgs || []).filter((m) => m.error);
if (!errs.length) return null;
return h("ul", { class: "problems" }, errs.map((m) =>
h("li", { class: "error" }, h("strong", null, "named-checkconf: "), m.text, m.object ? h("span", { class: "where" }, m.object) : null)));
}
export function diffView(text) {
if (!text) return h("div", { class: "muted" }, "No file changes.");
const box = h("div", { class: "diff" });
for (const line of text.replace(/\n$/, "").split("\n")) {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
else if (line.startsWith("+")) cls = "add";
else if (line.startsWith("-")) cls = "del";
else if (line.startsWith("@@")) cls = "hunk";
box.append(h("div", { class: cls }, line || " "));
}
return box;
}
// errorBox shows an API error with its problems and named-checkconf output.
export function errorBox(err) {
if (!err) return null;
const d = err.details || {};
let problems = d.problems || [];
// Don't repeat a single problem that is already the headline.
if (problems.length === 1 && err.message.includes(problems[0].message)) problems = [];
return h("div", { class: "msg err" },
h("div", null, err.message),
problemsList(problems),
checkList(d.check));
}
export function copyBox(value) {
const inp = input({ value, readOnly: true });
const btn = h("button", { type: "button", class: "small", onclick: async () => {
try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); }
} }, "Copy");
return h("div", { class: "copy-box" }, inp, btn);
}
// debounce delays fn until calls stop for ms.
export function debounce(fn, ms = 250) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jarl</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 13 L3 6 L5.5 8.5 L8 3 L10.5 8.5 L13 6 L13 13 Z' fill='%232f5fb3'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/style.css">
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<noscript><p style="padding:16px">Jarl's web interface needs JavaScript. The REST API and the <code>jarl</code> CLI work without it.</p></noscript>
<div id="app"></div>
<div id="toasts"></div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
// Package web embeds the web UI: a small single-page application that
// uses Jarl's REST API for everything it does.
package web
import (
"embed"
"io/fs"
)
//go:embed static
var static embed.FS
// FS returns the web UI files (index.html and assets/).
func FS() fs.FS {
sub, err := fs.Sub(static, "static")
if err != nil {
panic(err)
}
return sub
}