- Connections: the kernel's tracked connections, read and closed over ctnetlink (internal/conntrack, raw netlink like the drop log), with their NAT and TCP state; closing one makes the rules check its next packet again (GET /connections, POST /connections/close; hc connections; Traffic → Connections). - Rules can check every packet (every_packet, --every-packet): before established connections are accepted, so a drop or reject also stops connections already open. They can't accept. - A packet tracer (internal/trace): a table of its own, inet housecarl-trace, marks the packets a filter picks, `nft monitor trace` reports every rule and policy they meet in every table, and the tracer names what accepted or dropped them, Housecarl's rules by name (POST, GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time, at most 200 packets and 20 a second; the table goes when it ends. e2e: 157 checks, among them an upload a normal block doesn't stop and an every-packet one does, and a trace naming the blocking rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
15 KiB
JavaScript
359 lines
15 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 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: /^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: /^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"],
|
|
["nat", "#/nat", "NAT", "config"],
|
|
{ menu: "Objects", items: [
|
|
["zones", "#/zones", "Zones", "config"],
|
|
["sets", "#/sets", "Address sets", "config"],
|
|
["services", "#/services", "Services", "config"],
|
|
] },
|
|
{ menu: "Traffic", items: [
|
|
["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" })));
|
|
mount(app, h("div", { class: "shell" }, header, 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");
|
|
});
|