- CLI: name several servers and groups in the config file; select them with -s/--server NAME|GROUP|all|URL or JARL_SERVER. A command aimed at several servers is dry-run on all of them first and only runs if every one accepts it. Named servers always use their own token. - Reverse zones: /32 and /128 get single-address zones; networks between octet or nibble boundaries (e.g. a /22) are covered by several zones created together, from the CLI and the web UI. - Fix: under jarl.service's RestrictSUIDSGID, creating a view directory failed on chmod with the setgid bit; fall back to the permission bits. - Web UI: the review/live switch is a small header button, and the pending-changes bar only shows when there are pending changes. - TSIG key wording; the keys page points to restricted API tokens. - Docs: several servers (cli.md), a secondary server (operations.md), tokens vs TSIG keys (permissions.md); CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
7.5 KiB
JavaScript
201 lines
7.5 KiB
JavaScript
// Jarl web UI: router, layout and login.
|
|
import { h, mount, toast, errorBox, plural } from "./ui.js";
|
|
import { wordmark } from "./brand.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";
|
|
import * as stats from "./pages/stats.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: "" },
|
|
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
|
];
|
|
|
|
const navItems = [
|
|
["dashboard", "#/", "Dashboard"],
|
|
["stats", "#/stats", "Statistics"],
|
|
["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("img", { class: "login-logo", src: "/assets/logo.svg", alt: "", width: 104, height: 104 }),
|
|
h("h1", null, wordmark()),
|
|
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" });
|
|
let modeEl;
|
|
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: "#/", title: "Dashboard" },
|
|
h("img", { src: "/assets/logo.svg", alt: "", width: 30, height: 30 }),
|
|
h("span", { class: "brand-name" }, "Jarl"),
|
|
h("span", { class: "brand-version" }, me.version)),
|
|
navEl,
|
|
h("div", { class: "spacer" }),
|
|
modeEl = h("span"),
|
|
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);
|
|
modeHolder = modeEl;
|
|
renderNav();
|
|
renderBar();
|
|
}
|
|
|
|
let modeHolder;
|
|
|
|
function renderNav() {
|
|
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
|
}
|
|
|
|
// renderBar shows the pending-changes bar (only while there are pending
|
|
// changes) and the small review/live switch in the header.
|
|
function renderBar() {
|
|
if (!barEl) return;
|
|
const d = state.draft;
|
|
const n = d ? (d.ops || []).length : 0;
|
|
if (modeHolder) {
|
|
const live = state.immediate;
|
|
mount(modeHolder, h("button", {
|
|
type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false",
|
|
title: live ? "Edits are applied to named right away. Click to collect them for review first."
|
|
: "Edits are collected as pending changes and applied from Changes. Click to apply edits right away.",
|
|
onclick: () => setImmediate(!live),
|
|
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
|
|
}
|
|
mount(barEl, n > 0 ? 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")) : null);
|
|
}
|
|
|
|
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");
|
|
});
|