Changesets, as in Jarl: configuration changes are operations that can be staged in a changeset, checked as they are added (validation and nft -c), reviewed with the diff of the ruleset, and applied together as one history entry, then on probation like any change made over the network. The web UI collects edits for review by default (pending rows, a bar with Review and Apply now, a Changes page, a switch for live edits); hc stages with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has hc changeset create/list/show/diff/apply/discard/remove. Undo, import and takeover refuse a changeset. --dry-run shows the ruleset's diff. Bandwidth and quotas: rate limits in bytes, per source or destination; quotas per rule or per address that reset daily, weekly, monthly on a day, or by hand, with their usage carried across reloads, saved for reboots, shown by hc quotas and on the rules page, and reset with hc rule reset-quota. Rules that count traffic are checked before established connections are accepted (so they see every packet) and can't accept. Per-address limits group IPv6 by /64 by default (--group-v4/-v6). Per-source rate meters use update. The importer turns meters, byte rates and quotas into these fields. The examples in the web UI, hc's help and the docs use documentation addresses and generic names instead of the maintainer's network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
14 KiB
JavaScript
328 lines
14 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 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: /^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"],
|
|
] },
|
|
["log", "#/log", "Log", "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) 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);
|
|
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;
|
|
|
|
// 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);
|
|
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(() => 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");
|
|
});
|