Files
adamandClaude Opus 5.5 bdd7acfd3b Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:56 +01:00

309 lines
12 KiB
JavaScript

// Jarl web UI: router, layout and login.
import { h, mount, toast, errorBox, plural, lastList, modal } from "./ui.js";
import { wordmark, logo } from "./brand.js";
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt, useDraft } from "./api.js";
import * as totp from "./totp.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";
import * as blocking from "./pages/blocking.js";
import * as exposure from "./pages/exposure.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: /^views\/(\d+)(?:\/(\w+))?$/, page: views, nav: "views", params: ["id", "tab"] },
{ 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" },
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
];
const navItems = [
["dashboard", "#/", "Dashboard"],
["stats", "#/stats", "Statistics"],
["zones", "#/zones", "Zones"],
["blocking", "#/blocking", "Blocking"],
["views", "#/views", "Views"],
["exposure", "#/exposure", "Exposure"],
["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: "" }, routeGen = 0;
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 r = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
if (r.totp_required) { renderCodeStep(r.challenge); return; }
await loggedIn(r);
} catch (ex) {
mount(err, errorBox(ex));
pass.select();
}
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
mount(app, loginPanel(message, form));
user.focus();
}
function loginPanel(message, ...body) {
return h("div", { class: "login panel" },
logo(104, "login-logo"),
h("h1", null, wordmark()),
h("div", { class: "tag" }, "Rule your DNS holds"),
message ? h("div", { class: "msg warn" }, message) : null,
...body);
}
// renderCodeStep is the second login step for users with two-factor login.
function renderCodeStep(challenge) {
const code = h("input", { name: "code", autocomplete: "one-time-code", inputmode: "numeric", placeholder: "6-digit code", required: true, class: "totp-code" });
const err = h("div");
let recovery = false;
const hint = h("div", { class: "small muted" });
const toggle = h("a", { href: "#", class: "small", onclick: (e) => {
e.preventDefault();
recovery = !recovery;
code.placeholder = recovery ? "Recovery code, e.g. k7dm2-xq9pw" : "6-digit code";
code.inputMode = recovery ? "text" : "numeric";
toggle.textContent = recovery ? "Use the authenticator app" : "Lost your phone? Use a recovery code";
code.value = "";
code.focus();
} }, "Lost your phone? Use a recovery code");
mount(hint, "Enter the code from your authenticator app.");
const form = h("form", { onsubmit: async (e) => {
e.preventDefault();
mount(err);
try {
const me = await api("POST", "/auth/login/totp", { body: { challenge, code: code.value } });
await loggedIn(me);
} catch (ex) {
if (ex.code === "login_expired") { renderLogin(ex.message); return; }
mount(err, errorBox(ex));
code.select();
}
} }, hint, code, h("button", { class: "primary", type: "submit" }, "Verify"), toggle, err);
mount(app, loginPanel(null, form));
code.focus();
}
async function loggedIn(me) {
state.me = me;
state.csrf = me.csrf_token || "";
if (me.totp_setup_required) renderSetupRequired();
else await start();
}
// renderSetupRequired: the server requires two-factor login for this
// user (totp.require), and it isn't set up yet. Nothing else works until
// it is.
function renderSetupRequired() {
const box = h("div");
mount(app, h("div", { class: "login panel wide" },
logo(64, "login-logo"),
h("h1", null, "Set up two-factor login"),
h("p", null, "This server requires two-factor login for your account. Set up an authenticator app to continue."),
box,
h("div", { class: "small", style: { marginTop: "12px" } }, h("a", { href: "#", onclick: (e) => { e.preventDefault(); logout(); } }, "Log out"))));
totp.setup(box, state.me, async () => {
const me = await get("/auth/whoami");
await loggedIn(me);
});
}
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" },
logo(30),
wordmark({ rule: false, height: 19, cls: "brand-name" })),
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, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
modeHolder = modeEl;
renderNav();
renderBar();
}
let modeHolder;
// footer names the Jarl build: the release, or the commit for other
// builds, linked to it in the repository.
function footer(b) {
if (!b) return null;
let title = b.tag ? "Jarl " + b.version + " (release " + b.tag + ")" : "Jarl " + b.version + " (built without git)";
if (!b.tag && b.commit) {
title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : "");
if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since;
}
return h("footer", { class: "site" }, "Powered by ",
h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Jarl " + (b.tag || b.commit ? b.label : b.version)));
}
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 small", href: "#/changes" }, "Review"),
h("button", { class: "primary small apply-now", title: "Apply the pending changes now, without reviewing them first", onclick: (ev) => applyDraft(ev.target, d) }, "Apply now")) : null);
}
// applyDraft applies the draft changeset from the banner and stays on the
// page. If it is refused, a dialog says why and links to Changes.
async function applyDraft(btn, d) {
btn.disabled = true;
btn.textContent = "Applying…";
try {
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: {} });
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);
rerender();
} catch (e) {
btn.disabled = false;
btn.textContent = "Apply now";
const review = await modal("The changes were not applied", errorBox(e), [{ label: "Close", value: false }, { label: "Review changes", kind: "primary", value: true }], { wide: true });
if (review) location.hash = "#/changes";
}
}
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 };
if (!r.params && r.nav) lastList[r.nav] = location.hash;
renderNav();
// Keep the old page until the new one is drawn (switching a zone's
// tabs shouldn't blank the page); say "Loading…" only when it's slow.
// (Only while the new page has drawn nothing yet: pages that draw
// their frame first fill it in afterwards.)
const gen = ++routeGen;
const old = mainEl.firstChild;
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
try {
await r.page.render(mainEl, params, query);
} catch (e) {
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
} finally {
clearTimeout(slow);
}
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.");
}
});
setCodePrompt(totp.askCode);
(async function boot() {
try {
await loggedIn(await get("/auth/whoami"));
} 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");
});