Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
@@ -0,0 +1,270 @@
|
||||
// Housecarl web UI: router, layout, login and the probation banner.
|
||||
import { h, mount, toast, errorBox } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler } from "./api.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: /^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"],
|
||||
["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, 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();
|
||||
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" });
|
||||
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" },
|
||||
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, bannerEl, mainEl, footer(me.build)));
|
||||
renderNav();
|
||||
refreshProbation();
|
||||
}
|
||||
|
||||
// ---- 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");
|
||||
});
|
||||
Reference in new issue
Block a user