Files
housecarl/internal/web/static/assets/app.js
T
abrown38andClaude Opus 5.5 de075edd5d Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it
blocks, rejects and translates nothing: drops and rejects count and
accept (an accept only ends Housecarl's chain), policies accept and log
what they would drop, NAT only counts, MSS clamping and the flowtable
are left out, and raw rules that do more than accept, drop or reject are
left out with a warning. Comments mark what would happen ([would drop]).
Filter chains run after the other firewall's (+10) or before (-10); NAT
chains always first. Every change is checked by nftables in both forms.
No probation; takeovers and closing connections are refused. The boot
fallback filters nothing. internal/tablewatch puts the table back after
a flush ruleset. Mode in status, healthz, whoami, the web UI, the log
and the tracer; hc ruleset --enforcing. docs/shadow.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:11:01 +01:00

369 lines
16 KiB
JavaScript

// Housecarl web UI: router, layout, login and the probation banner.
import { h, mount, toast, errorBox, modal } from "./ui.js";
import { wordmark, logo } from "./brand.js";
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js";
import * as changes from "./pages/changes.js";
import * as connections from "./pages/connections.js";
import * as tracePage from "./pages/trace.js";
import * as matrixPage from "./pages/matrix.js";
import * as statsPage from "./pages/stats.js";
import * as dashboard from "./pages/dashboard.js";
import * as rules from "./pages/rules.js";
import * as nat from "./pages/nat.js";
import * as zones from "./pages/zones.js";
import * as sets from "./pages/sets.js";
import * as services from "./pages/services.js";
import * as log from "./pages/log.js";
import * as settings from "./pages/settings.js";
import * as importPage from "./pages/import.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 certificate from "./pages/certificate.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
{ pattern: /^rules$/, page: rules, nav: "rules" },
{ pattern: /^nat$/, page: nat, nav: "nat" },
{ pattern: /^matrix$/, page: matrixPage, nav: "matrix" },
{ pattern: /^zones$/, page: zones, nav: "zones" },
{ pattern: /^sets$/, page: sets, nav: "sets" },
{ pattern: /^services$/, page: services, nav: "services" },
{ pattern: /^log$/, page: log, nav: "log" },
{ pattern: /^connections$/, page: connections, nav: "connections" },
{ pattern: /^trace$/, page: tracePage, nav: "trace" },
{ pattern: /^stats$/, page: statsPage, nav: "stats" },
{ pattern: /^settings$/, page: settings, nav: "settings" },
{ pattern: /^import$/, page: importPage, nav: "import" },
{ 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: /^certificate$/, page: certificate, nav: "certificate" },
];
// The top bar. ["key", "#/href", "Label", permission?]
const navItems = [
["dashboard", "#/", "Dashboard"],
["rules", "#/rules", "Rules", "config"],
["matrix", "#/matrix", "Matrix", "config"],
["nat", "#/nat", "NAT", "config"],
{ menu: "Objects", items: [
["zones", "#/zones", "Zones", "config"],
["sets", "#/sets", "Address sets", "config"],
["services", "#/services", "Services", "config"],
] },
{ menu: "Traffic", items: [
["stats", "#/stats", "Statistics", "log"],
["log", "#/log", "Log", "log"],
["connections", "#/connections", "Connections", "log"],
["trace", "#/trace", "Trace", "log"],
] },
["changes", "#/changes", "Changes", "config"],
["history", "#/history", "History", "config"],
{ menu: "Server", items: [
["settings", "#/settings", "Settings and built-ins", "config"],
["import", "#/import", "Import a ruleset", "config"],
["certificate", "#/certificate", "Certificate", "admin"],
["audit", "#/audit", "Audit log", "audit"],
] },
];
const app = document.getElementById("app");
let mainEl, navEl, bannerEl, barEl, modeHolder, 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 me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
await loggedIn(me);
} 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" },
logo(120, { cls: "login-logo" }),
h("h1", null, wordmark()),
h("div", { class: "tag" }, "Keeper of the hold's door"),
message ? h("div", { class: "msg warn" }, message) : null,
form));
user.focus();
}
async function loggedIn(me) {
state.me = me;
state.csrf = me.csrf_token || "";
layout();
onDraftChange(renderBar);
if (can("config", true)) await loadDraft();
window.addEventListener("hashchange", route);
await route();
}
function layout() {
navEl = h("nav", { class: "main" });
mainEl = h("main");
bannerEl = h("div", { class: "probation-banner hidden", role: "alert" });
barEl = h("div");
modeHolder = can("config", true) ? h("span") : null;
const me = state.me;
const header = h("header", { class: "top" },
h("a", { class: "brand", href: "#/", title: "Dashboard" },
logo(30, { small: true }),
wordmark({ rule: false, height: 17, cls: "brand-name" })),
navEl,
h("div", { class: "header-right" },
modeHolder,
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
h("a", { href: "#/account" }, "Account"),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
// Shadow mode (housecarl.yaml): say so on every page.
const shadow = me.mode === "shadow" ? h("div", { class: "probation-banner shadow-banner", role: "status" },
h("strong", null, "Shadow mode:"), " Housecarl's ruleset is loaded but blocks, rejects and translates nothing; the other firewall decides. ",
"What its counters, log, tracer and statistics call drops are what Housecarl would drop. Changes aren't on probation.") : null;
mount(app, h("div", { class: "shell" }, header, shadow, barEl, bannerEl, mainEl, footer(me.build)));
renderNav();
refreshProbation();
}
// ---- pending changes: edits are staged for review (as in Jarl)
// renderBar shows the pending-changes bar (while there are pending
// changes) and the 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 right away (each on probation over the network). Click to collect them for review first."
: "Edits are collected as pending changes, reviewed with the ruleset's diff and applied together under Changes. Click to apply edits right away.",
onclick: () => { setImmediate(!live); rerender(); },
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
}
mount(barEl, n > 0 && !state.immediate ? h("div", { class: "pending-bar" },
h("strong", null, n === 1 ? "1 pending change" : n + " pending changes"),
h("span", { class: "muted" }, "Collected in changeset #" + d.id + "; not in force 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 bar and stays on the
// page. If it is refused, a dialog says why and leads to Changes.
export async function applyDraft(btn, d, description) {
btn.disabled = true;
const label = btn.textContent;
btn.textContent = "Applying…";
try {
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { description: description || "" } });
useDraft(null);
if (r.confirm_by && !r.confirm_token) toast("Applied changeset #" + d.id + " on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 8000);
else toast("Applied changeset #" + d.id + " (history #" + r.history_id + ")");
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
if (r.confirm_token) await autoConfirm(r.confirm_token);
else await refreshProbation();
return r;
} catch (e) {
btn.disabled = false;
btn.textContent = label;
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";
return null;
} finally {
rerender();
}
}
// ---- probation: changes made here roll back unless confirmed
let probationTimer = null, deadline = 0, pending = 0, checking = false;
// autoConfirm confirms a change with its token. The server closed the
// change's connection, so this request opens a new one through the new
// ruleset: it only gets through if this browser can still reach
// Housecarl. If it can't, the change rolls back by itself.
export async function autoConfirm(token) {
checking = true;
await refreshProbation();
try {
await api("POST", "/confirm", { body: { token }, timeout: 10000 });
checking = false;
toast("Confirmed automatically: this browser reached Housecarl again over a new connection");
} catch (e) {
checking = false;
toast("Couldn't confirm the change (" + (e.name === "AbortError" ? "no answer over a new connection" : e.message) + "): unless it is confirmed, it rolls back by itself", "warn", 10000);
}
await refreshProbation();
}
// refreshProbation asks whether changes wait for confirmation, and shows
// the banner with a countdown while they do.
export async function refreshProbation() {
if (!bannerEl || !can("config", false)) return;
let pr;
try { pr = await get("/probation"); } catch { return; }
pending = (pr.pending || []).length + (pr.takeovers || 0);
deadline = Date.now() + (pr.seconds_left || 0) * 1000;
drawBanner();
clearInterval(probationTimer);
if (pending) probationTimer = setInterval(drawBanner, 1000);
}
function drawBanner() {
const left = Math.max(0, Math.round((deadline - Date.now()) / 1000));
if (!pending) {
bannerEl.classList.add("hidden");
return;
}
if (left === 0) {
// Rolled back by now: look again (and redraw the page).
clearInterval(probationTimer);
setTimeout(async () => { await refreshProbation(); rerender(); toast("The unconfirmed changes were rolled back", "warn", 8000); }, 1500);
}
bannerEl.classList.remove("hidden");
const write = can("config", true);
if (checking) {
mount(bannerEl, h("strong", null, "Checking you can still reach Housecarl…"), " The change is confirmed once this browser gets through over a new connection.");
return;
}
mount(bannerEl,
h("strong", null, pending === 1 ? "1 change is on probation" : pending + " changes are on probation"),
" — rolled back in ", h("span", { class: "mono" }, left + "s"), " unless you confirm them. If this page stops answering, wait: the firewall puts itself back.",
write ? h("span", { class: "banner-actions" },
h("button", { class: "primary small", onclick: async () => {
try { await api("POST", "/confirm"); toast("Confirmed: kept, and loaded at boot"); } catch (e) { toast(e.message, "err"); }
refreshProbation();
} }, "Confirm"), " ",
h("button", { class: "small", onclick: async () => {
try { await api("POST", "/rollback"); toast("Rolled back", "warn"); } catch (e) { toast(e.message, "err"); }
await refreshProbation();
rerender();
} }, "Roll back now")) : null);
}
// footer names the build: the release, or the commit for other builds.
function footer(b) {
if (!b) return null;
let title = b.tag ? "Housecarl " + b.version + " (release " + b.tag + ")" : "Housecarl " + b.version;
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 }, "Housecarl " + (b.tag || b.commit ? b.label : b.version)));
}
function renderNav() {
const allowed = (n) => !n[3] || (n[3] === "admin" ? state.me.admin : can(n[3], false));
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
mount(navEl, navItems.map((n) => {
if (!n.menu) return allowed(n) ? link(n) : null;
const items = n.items.filter(allowed);
if (!items.length) return null;
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
}));
}
// menu is a drop-down that closes when an item is picked, on a click
// elsewhere and on Escape.
function menu(label, items, opts = {}) {
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
return d;
}
document.addEventListener("click", (e) => {
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
});
async function logout() {
try { await api("POST", "/auth/logout"); } catch { /* ignore */ }
state.me = null;
state.csrf = "";
window.removeEventListener("hashchange", route);
renderLogin("You have logged out.");
}
// stopTimers lets a page that refreshes itself stop when it is left.
let pageCleanup = null;
export function onLeave(fn) { pageCleanup = fn; }
async function route() {
if (!state.me) return;
if (pageCleanup) { pageCleanup(); pageCleanup = null; }
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();
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 draws the current page again (after a change).
export function rerender() {
if (current.page) route();
}
setChangeHandler((r) => r && r.confirm_token ? autoConfirm(r.confirm_token) : refreshProbation());
setUnauthorizedHandler(() => {
if (state.me) {
state.me = null;
window.removeEventListener("hashchange", route);
renderLogin("Your session has expired. Please log in again.");
}
});
(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, "Housecarl"), errorBox(e)));
}
})();
window.addEventListener("unhandledrejection", (e) => {
toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err");
});