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:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
// API client and shared application state.
import { toast } from "./ui.js";
export const state = {
me: null, // whoami response
csrf: "",
catalog: null,
};
export class APIError extends Error {
constructor(status, body) {
const e = (body && body.error) || {};
super(e.message || ("HTTP " + status));
this.status = status;
this.code = e.code || "";
this.details = e;
}
}
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// onChange is told about every applied change (the probation banner).
let onChange = () => {};
export function setChangeHandler(fn) { onChange = fn; }
// api calls the REST API. opts: {body, query}.
export async function api(method, path, opts = {}) {
const headers = { "Accept": "application/json" };
let body;
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
let url = "/api/v1" + path;
if (opts.query) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
if (!resp.ok) {
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
throw new APIError(resp.status, data);
}
return data;
}
export const get = (path, query) => api("GET", path, { query });
// change sends a configuration change and reports it: a toast with the
// history entry (to undo it from History) and one per new warning. It
// returns the response.
export async function change(method, path, body, opts = {}) {
const r = await api(method, path, { body, query: opts.query });
if (!opts.quiet) report(r);
return r;
}
export function report(r) {
if (!r) return;
if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
else if (r.applied && r.confirm_by) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
else if (r.applied) toast("Applied and saved (history #" + r.history_id + ")");
else if (!(r.changes || []).length) toast("Nothing to change");
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
if (r.applied) onChange();
}
// can reports whether the user may read (or write) a resource.
export function can(resource, write) {
const me = state.me;
if (!me) return false;
if (me.admin) return true;
const perms = me.permissions || [];
return perms.includes(resource + ":write") || (!write && perms.includes(resource + ":read"));
}
// catalog returns the option catalog (cached).
export async function catalog() {
if (!state.catalog) state.catalog = await get("/catalog");
return state.catalog;
}
+270
View File
@@ -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");
});
@@ -0,0 +1,71 @@
// Your account: password and API tokens.
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
import { get, api, state, catalog, can } from "../api.js";
import { rerender } from "../app.js";
import { revoke, rotateDialog } from "./users.js";
export async function render(el) {
const me = state.me;
const [tokens, cat] = await Promise.all([get("/me/tokens"), catalog()]);
const cur = input({ type: "password", autocomplete: "current-password" });
const pw = input({ type: "password", autocomplete: "new-password" });
const pw2 = input({ type: "password", autocomplete: "new-password" });
const err = h("div");
const change = async () => {
mount(err);
if (pw.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
try {
await api("POST", "/me/password", { body: { current: cur.value, password: pw.value } });
toast("Password changed; your other sessions are logged out");
cur.value = pw.value = pw2.value = "";
} catch (e) { mount(err, errorBox(e)); }
};
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, me.user.username),
h("div", { class: "muted" }, me.admin ? "Administrator" : "May: " + (me.permissions.join(", ") || "nothing")))),
me.method === "session" ? h("div", { class: "panel" },
h("h2", null, "Password"),
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw), field("Repeat", pw2)), err,
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: change }, "Change password"))) : null,
h("div", { class: "panel" },
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
h("div", { class: "muted" }, "For the hc CLI and scripts: 'hc config set token'.")),
h("button", { onclick: () => newToken(cat) }, "New token")),
table([
{ label: "Name", render: (t) => t.name },
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
{ label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" },
{ label: "", tdClass: "actions-cell", render: (t) => [
h("button", { class: "small reveal", onclick: () => rotateDialog(t) }, "Rotate"), " ",
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
], tokens, { empty: "No tokens." })));
}
async function newToken(cat) {
const name = input({ placeholder: "laptop, backup script…" });
// Only roles within what the user may do: a token can never do more
// than its user, so offering more would only mislead.
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
const [res, acc] = g.split(":");
return can(res, acc === "write");
}));
const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
const err = h("div");
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
{ label: "Cancel", value: null },
{ label: "Create", kind: "primary", onclick: async () => {
mount(err);
try {
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
rerender();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
]);
}
+26
View File
@@ -0,0 +1,26 @@
// The audit log: logins, users, tokens, changes, confirmations and
// rollbacks, takeovers, dynamic set entries.
import { h, mount, table, time, input, debounce, badge } from "../ui.js";
import { get } from "../api.js";
export async function render(el) {
const search = input({ type: "search", placeholder: "user, action, object or detail" });
const out = h("div");
const draw = async () => {
const q = search.value.trim();
const evs = await get("/audit", { q, limit: 300 });
mount(out, table([
{ label: "When", render: (e) => time(e.time) },
{ label: "User", render: (e) => [e.username || "–", e.auth ? h("span", { class: "muted small" }, " " + e.auth) : null] },
{ label: "From", render: (e) => h("span", { class: "mono small" }, e.ip || "") },
{ label: "Action", render: (e) => h("span", { class: "mono" }, e.action) },
{ label: "Object", render: (e) => e.object },
{ label: "Result", render: (e) => badge(e.result, e.result === "success" ? "ok" : "err") },
{ label: "Detail", render: (e) => h("span", { class: "small" }, e.detail) },
], evs, { empty: "Nothing logged" + (q ? " that matches." : " yet.") }));
};
search.addEventListener("input", debounce(draw, 300));
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"))),
h("div", { class: "filter-row" }, search), h("div", { class: "panel" }, out));
await draw();
}
@@ -0,0 +1,86 @@
// The web UI's certificate: obtained through Jarl (tls.acme), its state,
// the last check, and buttons to test and renew.
import { h, mount, table, time, fmtTime, badge, errorBox, toast } from "../ui.js";
import { get, api } from "../api.js";
import { onLeave, rerender } from "../app.js";
const stateBadge = {
ok: ["valid", "ok"], working: ["working…", "accent"], error: ["failing", "err"], none: ["none yet", ""],
};
export async function render(el) {
const r = await get("/certificate");
if (r.source !== "acme") {
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"))),
h("div", { class: "panel" },
r.source === "file"
? h("p", null, "The web UI uses the certificate in tls.cert_file, which you renew yourself.")
: h("p", null, "The web UI is plain HTTP. Housecarl can get its own certificate from Let's Encrypt, with Jarl publishing the challenge: set tls.acme and jarl in housecarl.yaml (see docs/certificates.md)."),
));
return;
}
const st = r.status;
const out = h("div");
const checkBox = h("div");
const busy = st.state === "working";
const testBtn = h("button", { disabled: busy, onclick: async () => {
testBtn.disabled = true;
mount(checkBox, h("div", { class: "msg info" }, h("span", { class: "spinner" }), "Checking: writing a test record through Jarl and waiting until it is seen (up to a few minutes)…"));
try {
const c = await api("POST", "/certificate/check");
mount(checkBox, checkView(c));
} catch (e) { mount(checkBox, errorBox(e)); }
testBtn.disabled = false;
} }, "Test");
const renewBtn = h("button", { class: "primary", disabled: busy, onclick: async () => {
try {
await api("POST", "/certificate/renew");
toast("Getting a new certificate…");
setTimeout(rerender, 1500);
} catch (e) { toast(e.message, "err", 8000); }
} }, st.not_after ? "Renew now" : "Get the certificate now");
const [label, kind] = stateBadge[st.state] || [st.state, ""];
mount(out,
h("div", { class: "panel" },
h("dl", { class: "kv" },
h("dt", null, "Name"), h("dd", null, h("strong", null, st.domain), " ", badge(label, kind)),
h("dt", null, "From"), h("dd", null, st.staging ? ["Let's Encrypt staging ", badge("not trusted by browsers", "warn")] : st.directory),
st.not_after ? [h("dt", null, "Valid until"), h("dd", null, fmtTime(st.not_after), " (", days(st.not_after), "), issued by ", st.issuer)] : null,
st.renew_at ? [h("dt", null, "Renews"), h("dd", null, fmtTime(st.renew_at))] : null,
st.last_attempt ? [h("dt", null, "Last attempt"), h("dd", null, time(st.last_attempt))] : null,
st.next_attempt && !busy ? [h("dt", null, "Next check"), h("dd", null, time(st.next_attempt))] : null),
st.last_error ? h("div", { class: "msg err" }, st.last_error) : null,
st.staging && st.state === "ok" ? h("div", { class: "msg warn" }, "This certificate is from Let's Encrypt's staging service, to prove everything works. Browsers warn about it. Set tls.acme.directory: letsencrypt in housecarl.yaml and restart housecarld for a trusted one.") : null,
h("div", { class: "actions mt" }, testBtn, renewBtn,
h("span", { class: "muted small" }, "Test runs the check without asking Let's Encrypt; a certificate order always runs it first."))),
checkBox);
if (st.check) mount(checkBox, checkView(st.check));
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"),
h("div", { class: "muted" }, "The web UI's certificate, obtained from the CA with Jarl publishing the DNS-01 challenge."))), out);
if (busy) {
const t = setTimeout(rerender, 3000);
onLeave(() => clearTimeout(t));
}
}
// days says how long until a time: "in 89 days", "in 5 hours".
function days(iso) {
const ms = new Date(iso) - Date.now();
if (ms <= 0) return "ended";
const d = Math.floor(ms / 86400000);
return d >= 2 ? "in " + d + " days" : "in " + Math.max(1, Math.round(ms / 3600000)) + " hours";
}
function checkView(c) {
const mark = { ok: ["ok", "ok"], fail: ["failed", "err"], warn: ["warning", "warn"], skip: ["skipped", ""] };
return h("div", { class: "panel" },
h("h2", null, "Check, ", time(c.time), " ", c.ok ? badge("passed", "ok") : badge("failed", "err")),
table([
{ label: "Step", render: (s) => h("strong", null, s.name) },
{ label: "", render: (s) => badge(...(mark[s.status] || [s.status, ""])) },
{ label: "What happened", render: (s) => [s.detail,
(s.queries || []).length ? h("details", { class: "small", open: s.status === "fail" },
h("summary", null, "DNS questions (" + s.queries.length + ")"),
h("pre", { class: "wrap" }, s.queries.join("\n"))) : null] },
], c.steps));
}
@@ -0,0 +1,65 @@
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules
// and the latest drops.
import { h, mount, table, badge, problemsList, time } from "../ui.js";
import { get, can } from "../api.js";
import { onLeave } from "../app.js";
import { describe, hits, humanCount } from "../widgets.js";
export async function render(el) {
const draw = async () => {
const st = await get("/status");
const config = can("config", false), logs = can("log", false);
const [rules, cs, log] = await Promise.all([
config ? get("/rules") : [],
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
logs ? get("/log", { limit: 8 }).catch(() => []) : [],
]);
const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0)
.sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8);
const card = (label, value, sub, href) => {
const body = [h("div", { class: "label" }, label), h("div", { class: "value" }, String(value)), h("div", { class: "sub" }, sub)];
return href ? h("a", { class: "card link", href }, body) : h("div", { class: "card" }, body);
};
const policyDrops = (c) => cs["p-" + c] ? humanCount(cs["p-" + c].packets) : "–";
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Dashboard"))),
st.unconfigured ? h("div", { class: "msg info" }, "Nothing is configured yet, so Housecarl applies no ruleset. Write the rules that let you in (SSH, this web UI) first: the input policy drops what no rule accepts. Or ",
h("a", { href: "#/import" }, "import your ruleset"), ".") :
!st.applied ? h("div", { class: "msg err" }, "The ruleset isn't in force: ", st.apply_error) : null,
st.other_tables.length ? h("div", { class: "msg info" }, "Other tables in the ruleset: ", h("span", { class: "mono" }, st.other_tables.join(", ")),
". A packet must get past them too. Once Housecarl's ruleset replaces one, 'hc takeover' deletes it.") : null,
h("div", { class: "cards" },
card("Ruleset", st.applied ? "in force" : st.unconfigured ? "none yet" : "not in force", "table inet housecarl", null),
card("Rules", st.counts.rule, st.counts.zone + " zones · " + st.counts.set + " sets", "#/rules"),
card("NAT", st.counts.forward + st.counts.snat + st.counts.redirect, st.counts.forward + " port forwards", "#/nat"),
card("Dropped by policy", policyDrops("input") + " / " + policyDrops("forward"), "input / forward", logs ? "#/log" : null),
card("Problems", st.problems.length, st.problems.length ? "see below" : "none", null)),
st.problems.length ? h("div", { class: "panel" }, h("h2", null, "Problems"), problemsList(st.problems)) : null,
h("div", { class: "panel" }, h("h2", null, "Interfaces"), table([
{ label: "Interface", render: (i) => [h("span", { class: "dot " + (i.up ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
{ label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") },
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ")) },
], st.interfaces.filter((i) => i.name !== "lo"))),
busiest.length ? h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
table([
{ label: "Chain", render: (r) => r.chain },
{ label: "Rule", render: (r) => r.raw || describe(r) },
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment },
], busiest)) : null,
log.length ? h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
table([
{ label: "When", render: (e) => time(e.time) },
{ label: "Why", render: (e) => e.prefix },
{ label: "From", render: (e) => h("span", { class: "mono" }, e.source) },
{ label: "To", render: (e) => h("span", { class: "mono" }, e.destination, e.destination_port ? ":" + e.destination_port : "") },
{ label: "Protocol", render: (e) => e.protocol },
], log)) : null);
};
await draw();
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
onLeave(() => clearInterval(t));
}
@@ -0,0 +1,69 @@
// History: every configuration change, with undo.
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
export async function render(el, params) {
if (params.id) return entry(el, params.id);
const hs = await get("/history", { limit: 200 });
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"),
h("div", { class: "muted" }, "Every configuration change, newest first. Undo applies a change backwards, if nothing it touched has changed since."))),
h("div", { class: "panel" }, table([
{ label: "#", render: (e) => h("a", { href: "#/history/" + e.id }, String(e.id)) },
{ label: "When", render: (e) => time(e.time) },
{ label: "By", render: (e) => [e.username, h("span", { class: "muted small" }, " via " + e.via)] },
{ label: "Change", render: (e) => [h("a", { href: "#/history/" + e.id }, e.description),
e.reverted_by ? [" ", badge("undone by #" + e.reverted_by, "")] : null, e.revert_of ? [" ", badge("undoes #" + e.revert_of, "accent")] : null] },
can("config", true) ? { label: "", tdClass: "actions-cell", render: (e) => e.reverted_by ? null : h("button", { class: "small reveal", onclick: () => undo(e) }, "Undo") } : null,
].filter(Boolean), hs, { empty: "No changes yet." })));
}
async function undo(e) {
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
try {
await change("POST", "/history/" + e.id + "/revert");
rerender();
} catch (err) { modal("Can't undo", errorBox(err)); }
}
async function entry(el, id) {
const e = await get("/history/" + id);
mount(el,
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "#" + e.id + ": " + e.description),
h("div", { class: "muted" }, new Date(e.time).toLocaleString(), " by ", e.username, " via ", e.via,
e.reverted_by ? [" · undone by ", h("a", { href: "#/history/" + e.reverted_by }, "#" + e.reverted_by)] : null,
e.revert_of ? [" · undoes ", h("a", { href: "#/history/" + e.revert_of }, "#" + e.revert_of)] : null)),
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
e.changes.map((c) => h("div", { class: "panel" },
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
fieldsTable(c))));
}
function action(c) {
if (!c.before) return "Created";
if (!c.after) return "Deleted";
return "Changed";
}
// fieldsTable lists the fields that differ (all of them for a create or
// delete).
function fieldsTable(c) {
const a = c.before || {}, b = c.after || {};
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
const show = (v, k) => {
if (v === undefined || v === "") return "–";
if (k === "options") return v.length ? v.map((o) => o.name + "=" + o.value).join("; ") : "none";
if (k === "pools") return v.length ? v.map((p) => p.start + "-" + p.end).join(", ") : "none";
if (k === "probation_seconds") return v ? v + "s" : "default";
return typeof v === "string" ? v : JSON.stringify(v);
};
const rows = keys.filter((k) => k !== "id").filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]) || !c.before || !c.after);
return table([
{ label: "Field", render: (k) => h("strong", null, k) },
c.before ? { label: "Before", render: (k) => h("span", { class: "mono small" }, show(a[k], k)) } : null,
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
].filter(Boolean), rows);
}
@@ -0,0 +1,41 @@
// Import: an existing nftables ruleset as Housecarl's configuration.
import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js";
import { api } from "../api.js";
import { refreshProbation } from "../app.js";
export async function render(el) {
const json = textarea("", { rows: 6, class: "mono", placeholder: "the output of: sudo nft -j list ruleset" });
const text = textarea("", { rows: 4, class: "mono", placeholder: "optional, the output of: sudo nft -a list ruleset (keeps what Housecarl can't express as raw rules)" });
const defines = textarea("", { rows: 4, class: "mono", placeholder: "optional, your /etc/nftables.conf: its defines name the sets" });
const table = input({ placeholder: "inet main (when there are several)", class: "mono" });
const replace = checkbox("replace Housecarl's configuration", false);
const out = h("div");
const run = async (dry) => {
mount(out);
let parsed;
try { parsed = JSON.parse(json.value); } catch { mount(out, h("div", { class: "msg err" }, "The ruleset isn't JSON: paste the output of nft -j list ruleset.")); return; }
try {
const r = await api("POST", "/import/nft", { body: { json: parsed, text: text.value, defines: defines.value, table: table.value.trim(), replace: replace.input.checked },
query: dry ? { dry_run: 1 } : undefined });
const st = r.state;
mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
dry ? "Would import: " : "Imported: ",
`${st.zones.length} zones, ${st.sets.length} sets, ${st.services.length} services, ${st.rules.length} rules, ${st.forwards.length} port forwards, ${st.snats.length} source NAT rules, ${st.redirects.length} redirects.`,
r.confirm_by ? " It is on probation: confirm it in the banner." : ""),
r.notes.length ? h("div", { class: "panel" }, h("h2", null, "Notes"), h("ul", null, r.notes.map((n) => h("li", null, n)))) : null,
r.raw.length ? h("div", { class: "panel" }, h("h2", null, "Kept as raw rules"), h("ul", { class: "mono small" }, r.raw.map((n) => h("li", null, n)))) : null,
!dry ? h("p", null, "Check the ", h("a", { href: "#/rules" }, "rules"), " and ", h("a", { href: "#/nat" }, "NAT"),
"; both rulesets apply until the old table goes: ", copyBox("sudo hc takeover \"inet main\"")) : null);
if (!dry) refreshProbation();
} catch (e) { mount(out, errorBox(e)); }
};
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Import a ruleset"),
h("div", { class: "muted" }, "Turns an nftables ruleset into zones, sets, services, rules, port forwards and NAT. On the firewall, 'sudo hc import --defines /etc/nftables.conf -n' does the same from the command line."))),
h("div", { class: "panel" }, h("div", { class: "form-grid" },
field("Ruleset (JSON)", json), field("Ruleset (text)", text), field("Ruleset file", defines), field("Table", table), h("label"), replace),
h("div", { class: "actions form-actions" },
h("button", { onclick: () => run(true) }, "Preview"),
h("button", { class: "primary", onclick: () => run(false) }, "Import"))),
out);
}
+34
View File
@@ -0,0 +1,34 @@
// The log: packets rules (with logging on) and the policies logged,
// refreshed every few seconds.
import { h, mount, input, debounce } from "../ui.js";
import { get } from "../api.js";
import { onLeave } from "../app.js";
export async function render(el, params, query) {
const search = input({ type: "search", placeholder: "address, interface, rule…", value: query.get("q") || "" });
const out = h("div");
let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const addr = (a, port) => port ? (a.includes(":") ? "[" + a + "]:" + port : a + ":" + port) : a;
const draw = async () => {
const es = await get("/log", { limit: 300, q: search.value.trim() });
mount(out, es.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
h("tbody", null, es.map((e) => h("tr", null,
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix),
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
h("td", null, e.protocol, e.icmp_type ? " " + e.icmp_type : "", e.tcp_flags ? h("span", { class: "muted small" }, " " + e.tcp_flags) : null)))))) :
h("div", { class: "empty" }, "Nothing logged yet. Turn logging on for a rule (Edit → Log), or for what the policies drop (Settings)."));
};
search.addEventListener("input", debounce(draw, 300));
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Log"),
h("div", { class: "muted" }, "Packets logged by rules and policies, newest first (the last 2000 are kept)."))),
h("div", { class: "filter-row" }, search, pause),
h("div", { class: "panel" }, out));
await draw();
const t = setInterval(() => { if (live && !document.hidden) draw().catch(() => {}); }, 3000);
onLeave(() => clearInterval(t));
}
+131
View File
@@ -0,0 +1,131 @@
// NAT: port forwards (with hairpin and IPv6 pinholes), source NAT and
// redirects.
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
export async function render(el) {
const [fwds, snats, redirects, cs] = await Promise.all([get("/forwards"), get("/snats"), get("/redirects"), counters()]);
const write = can("config", true);
const actions = (kind, x, edit) => write ? [
h("button", { class: "small reveal", onclick: () => edit(x) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => toggle(kind, x) }, x.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small reveal", onclick: () => del(kind, x) }, "Delete")] : null;
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "NAT"),
h("div", { class: "muted" }, "Port forwards publish inside services; source NAT hides inside addresses behind the firewall's; redirects send traffic to the firewall itself."))),
h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Port forwards"), write ? h("button", { class: "primary small", onclick: () => forwardDialog(null) }, "Add port forward") : null),
table([
{ label: "Name", render: (f) => f.name || h("span", { class: "muted" }, "–") },
{ label: "Public", render: (f) => h("span", { class: "mono" }, (f.public_addresses || []).join(", ") || "(external zones)", " ", f.protocols.join("+"), "/", f.ports) },
{ label: "Inside", render: (f) => h("span", { class: "mono" }, (f.to || "") + (f.to_port ? ":" + f.to_port : ""), f.to6 ? h("div", null, f.to6) : null) },
{ label: "From", render: (f) => (f.sources || []).join(", ") || h("span", { class: "muted" }, "anywhere") },
{ label: "Hairpin", render: (f) => f.hairpin ? badge((f.hairpin_sources || []).length ? "from " + f.hairpin_sources.join(", ") : "yes", "ok") : "" },
{ label: "Hits", render: (f) => hits(cs, "f" + f.id, "f" + f.id + "-6") },
{ label: "", tdClass: "actions-cell", render: (f) => actions("forwards", f, forwardDialog) },
], fwds, { empty: "No port forwards.", rowClass: (x) => x.disabled ? "disabled" : "" })),
h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Source NAT"), write ? h("button", { class: "primary small", onclick: () => snatDialog(null) }, "Add source NAT") : null),
table([
{ label: "#", render: (x, i) => String(i + 1) },
{ label: "Match", render: (x) => describe(x) },
{ label: "To", render: (x) => x.to ? h("span", { class: "mono" }, x.to) : "masquerade" },
{ label: "Hits", render: (x) => hits(cs, "s" + x.id) },
{ label: "Comment", render: (x) => x.comment },
{ label: "", tdClass: "actions-cell", render: (x) => actions("snats", x, snatDialog) },
], snats, { empty: "No source NAT.", rowClass: (x) => x.disabled ? "disabled" : "" })),
h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Redirects"), write ? h("button", { class: "primary small", onclick: () => redirectDialog(null) }, "Add redirect") : null),
table([
{ label: "Match", render: (x) => describe(x) },
{ label: "To port", render: (x) => x.to_port || h("span", { class: "muted" }, "the service's") },
{ label: "Hits", render: (x) => hits(cs, "d" + x.id) },
{ label: "Comment", render: (x) => x.comment },
{ label: "", tdClass: "actions-cell", render: (x) => actions("redirects", x, redirectDialog) },
], redirects, { empty: "No redirects.", rowClass: (x) => x.disabled ? "disabled" : "" })));
}
async function toggle(kind, x) {
try { await change("PATCH", "/" + kind + "/" + x.id, { disabled: !x.disabled }); rerender(); } catch (e) { modal("Not changed", errorBox(e)); }
}
async function del(kind, x) {
if (!await confirmDialog("Delete", "Delete " + (x.name || x.comment || "this " + kind.replace(/s$/, "")) + "?", "Delete", "danger")) return;
try { await change("DELETE", "/" + kind + "/" + x.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
}
// dialog runs an add/edit dialog whose fields give value().
async function dialog(title, kind, x, fields, value) {
const err = h("div");
const save = async (dry) => {
mount(err);
try {
await change(x ? "PATCH" : "POST", x ? "/" + kind + "/" + x.id : "/" + kind, value(), { query: dry ? { dry_run: 1 } : undefined });
if (dry) return false;
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
};
await modal(title, h("div", null, h("div", { class: "form-grid" }, fields), err), [
{ label: "Cancel", value: null },
{ label: "Check", onclick: () => save(true) },
{ label: x ? "Save" : "Add", kind: "primary", onclick: () => save(false) },
], { wide: true, sticky: true });
}
async function forwardDialog(f) {
const n = await names();
f = f || { protocols: ["tcp"], hairpin: true };
const name = input({ value: f.name || "", placeholder: "jellyfin" });
const tcp = checkbox("TCP", f.protocols.includes("tcp")), udp = checkbox("UDP", f.protocols.includes("udp"));
const ports = input({ value: f.ports || "", placeholder: "8096 or 7777-7780", class: "mono" });
const pub = listEditor(f.public_addresses, { suggestions: n.sets, placeholder: "your public IPv4 address, or a set", addLabel: "Add address" });
const to = input({ value: f.to || "", placeholder: "10.0.10.57", class: "mono" });
const toPort = input({ value: f.to_port || "", placeholder: "the same", class: "mono short" });
const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:10::57 (optional)", class: "mono" });
const src = listEditor(f.sources, { suggestions: n.sets, placeholder: "anywhere", addLabel: "Add source" });
const hairpin = checkbox("the public address also works from inside (hairpin NAT)", f.hairpin);
const hairSrc = listEditor(f.hairpin_sources, { suggestions: n.sets, placeholder: "all inside sources", addLabel: "Add source" });
const comment = input({ value: f.comment || "" });
await dialog(f.id ? "Edit port forward" : "Add port forward", "forwards", f.id ? f : null, [
field("Name", name),
field("Protocols", h("div", null, tcp, udp)),
field("Public port", ports, "A port or a range."),
field("Public addresses", pub, "None: any address arriving on the external zones (hairpin then needs an address)."),
field("Inside host", h("div", null, to, " port ", toPort), "IPv4: the connection is translated."),
field("Inside IPv6 host", to6, "Optional: the port is also opened to this IPv6 host, on its own port (no translation)."),
field("From", src, "Only these sources may connect."),
field("Hairpin", h("div", null, hairpin, hairSrc)),
field("Comment", comment),
], () => ({ name: name.value.trim(), protocols: [tcp.input.checked ? "tcp" : "", udp.input.checked ? "udp" : ""].filter(Boolean),
ports: ports.value.trim(), public_addresses: pub.value(), to: to.value.trim(), to_port: toPort.value.trim(), to6: to6.value.trim(),
sources: src.value(), hairpin: hairpin.input.checked, hairpin_sources: hairSrc.value(), comment: comment.value.trim() }));
}
async function snatDialog(x) {
const n = await names();
x = x || {};
const match = matchEditor(x, n);
const to = input({ value: x.to || "", placeholder: "masquerade (the outgoing interface's address)", class: "mono" });
const comment = input({ value: x.comment || "" });
await dialog(x.id ? "Edit source NAT" : "Add source NAT", "snats", x.id ? x : null, [
...match.flat(),
field("Translate to", to, "Empty: masquerade. IPv4 only unless the family is IPv6 (NAT66)."),
field("Comment", comment),
], () => ({ ...match.value(), to: to.value.trim(), comment: comment.value.trim() }));
}
async function redirectDialog(x) {
const n = await names();
x = x || {};
const match = matchEditor(x, n, { out: false });
const toPort = input({ value: x.to_port || "", placeholder: "the service's port", class: "mono short" });
const comment = input({ value: x.comment || "" });
await dialog(x.id ? "Edit redirect" : "Add redirect", "redirects", x.id ? x : null, [
...match.flat(),
field("To local port", toPort),
field("Comment", comment),
], () => ({ ...match.value(), to_port: toPort.value.trim(), comment: comment.value.trim() }));
}
+155
View File
@@ -0,0 +1,155 @@
// Rules: each chain's rules in order, with their hits; add, edit, move.
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
const chains = [
["input", "Input", "Traffic to the firewall itself."],
["forward", "Forward", "Traffic through the firewall, between zones."],
["output", "Output", "Traffic from the firewall itself."],
["early", "Early", "Before connection tracking: cheap drops of what can never be right."],
];
// The built-ins of each chain, as the ruleset has them (settings).
const builtins = {
early: [["antispoof", "antispoof", "Impossible sources from external zones dropped"], ["antispoof", "antispoof6", "…and IPv6 ones"]],
input: [["loopback", "loopback", "Everything on lo accepted"], ["established", "established", "Established and related connections accepted"],
["drop_invalid", "invalid", "Invalid packets dropped"], ["icmpv6", "icmpv6", "ICMPv6 errors and echo accepted"],
["icmpv6", "icmpv6-nd", "Neighbour discovery accepted"], ["icmpv6", "icmpv6-mld", "MLD from link-local accepted"],
["ping_internal", "ping", "Ping from internal zones accepted"], ["dhcpv6_client", "dhcpv6-client", "DHCPv6 replies on external zones accepted"]],
forward: [["mss_clamp", "mss-clamp", "TCP MSS clamped on external zones"], ["established", "established", "Established and related connections accepted"],
["drop_invalid", "invalid", "Invalid packets dropped"], ["icmpv6", "icmpv6", "ICMPv6 errors let through"], ["", "", "Port forwards let through (NAT page)"]],
output: [],
};
export async function render(el, params, query) {
const chain = query.get("chain") || "input";
const [rules, settings, cs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters()]);
const write = can("config", true);
const info = chains.find((c) => c[0] === chain) || chains[0];
const policy = settings[chain + "_policy"];
const bi = (builtins[chain] || []).filter(([flag]) => flag === "" || settings.builtins[flag]);
const actionBadge = (r) => badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Rules"), h("div", { class: "muted" }, "Checked top to bottom; the first rule that decides wins. ", info[2])),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => ruleDialog(null, chain) }, "Add rule")) : null),
h("div", { class: "tabs" }, chains.map(([k, label]) => h("a", { href: "#/rules?chain=" + k, class: k === chain ? "active" : "" }, label))),
h("div", { class: "panel" },
bi.length ? h("div", { class: "builtins muted small" }, h("strong", null, "Added by Housecarl first: "),
bi.map(([, mark, text], i) => [i ? " · " : "", text, mark ? [" (", hits(cs, "b-" + mark), ")"] : ""]), " — ",
h("a", { href: "#/settings" }, "settings")) : null,
table([
{ label: "#", render: (r, i) => String(i + 1) },
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
: [describe(r), extras(r)] },
{ label: "Action", render: actionBadge },
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment },
write ? { label: "", tdClass: "actions-cell", render: (r, i) => [
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rules[i - 1].id }) }, "↑"), " ",
h("button", { class: "small reveal", title: "Move down", disabled: i === rules.length - 1, onclick: () => move(r, { after: rules[i + 1].id }) }, "↓"), " ",
h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
] } : null,
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => r.disabled ? "disabled" : "" }),
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
}
function extras(r) {
const x = [];
if ((r.ct_state || []).length) x.push("state " + r.ct_state.join(","));
if ((r.ct_status || []).length) x.push("status " + r.ct_status.join(","));
if ((r.days || []).length) x.push(r.days.join(","));
if (r.hour_from) x.push(r.hour_from + "–" + r.hour_to);
if (r.limit) x.push((r.limit.over ? "over " : "") + r.limit.rate + "/" + r.limit.per + (r.limit.per_source ? " per source" : ""));
if (r.conn_limit) x.push("over " + r.conn_limit + " connections");
return x.length ? h("div", { class: "muted small" }, x.join(" · ")) : null;
}
async function move(r, where) {
try { await change("POST", "/rules/" + r.id + "/move", where, { quiet: true }); rerender(); } catch (e) { modal("Not moved", errorBox(e)); }
}
async function toggle(r) {
try { await change("PATCH", "/rules/" + r.id, { disabled: !r.disabled }); rerender(); } catch (e) { modal("Not changed", errorBox(e)); }
}
async function del(r) {
if (!await confirmDialog("Delete rule", "Delete " + (r.comment || "this rule") + " (" + (r.raw || describe(r)) + ")?", "Delete", "danger")) return;
try { await change("DELETE", "/rules/" + r.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
}
// ruleDialog adds (r null) or edits a rule.
export async function ruleDialog(r, chain) {
const n = await names();
r = r || { chain, action: "accept" };
const ch = select([["input", "input (to the firewall)"], ["forward", "forward (through it)"], ["output", "output (from it)"], ["early", "early (before conntrack)"]], r.chain || chain);
const match = matchEditor(r, n);
const action = select([["accept", "accept"], ["drop", "drop"], ["reject", "reject"], ["log", "log only (and go on)"]], r.action);
const rejectWith = select([["", "with an ICMP error"], ["tcp-reset", "with a TCP reset (TCP rules only)"]], r.reject_with || "");
const showReject = () => rejectWith.classList.toggle("hidden", action.value !== "reject");
action.addEventListener("change", showReject);
showReject();
const states = listEditor(r.ct_state, { suggestions: ["new", "established", "related", "invalid", "untracked"], placeholder: "new", mono: false, addLabel: "Add state" });
const statuses = listEditor(r.ct_status, { suggestions: ["dnat", "snat"], placeholder: "dnat", mono: false, addLabel: "Add status" });
const days = listEditor(r.days, { suggestions: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], placeholder: "Monday", mono: false, addLabel: "Add day" });
const hourFrom = input({ value: r.hour_from || "", placeholder: "08:00", class: "short" });
const hourTo = input({ value: r.hour_to || "", placeholder: "17:30", class: "short" });
const l = r.limit || {};
const rate = input({ value: l.rate || "", placeholder: "10", class: "short", type: "number", min: 1 });
const per = select(["second", "minute", "hour", "day"], l.per || "minute");
const burst = input({ value: l.burst || "", placeholder: "burst", class: "short", type: "number", min: 0 });
const over = checkbox("match beyond the rate (to drop floods)", l.over);
const perSource = checkbox("per source address", l.per_source);
const conn = input({ value: r.conn_limit || "", placeholder: "e.g. 20", class: "short", type: "number", min: 1 });
const log = checkbox("log matching packets (Log page)", r.log);
const logPrefix = input({ value: r.log_prefix || "", placeholder: "default: hc CHAIN #ID ACTION:" });
const raw = textarea(r.raw || "", { rows: 2, class: "mono", placeholder: "iifname != \"wan\" ct state invalid ip daddr @trusted_v4 accept" });
const comment = input({ value: r.comment || "" });
const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled);
const err = h("div");
const body = h("div", null,
h("div", { class: "form-grid" },
field("Chain", ch),
...match.flat(),
field("Action", h("div", null, action, " ", rejectWith)),
field("Connection state", states),
field("Connection status", statuses, "dnat: the connection was port forwarded."),
field("Days", days),
field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight."),
field("Rate limit", h("div", null, rate, " packets per ", per, " ", burst, over, perSource), "Empty: no limit."),
field("Connection limit", conn, "Match a source that already has more than this many connections."),
field("Log", h("div", null, log, logPrefix)),
field("Raw nftables", raw, "Instead of all the above: one nftables rule, for what Housecarl can't say. Checked by nftables."),
field("Comment", comment),
r.id ? [h("label"), disabled] : null),
err);
await modal(r.id ? "Edit rule" : "Add rule", body, [
{ label: "Cancel", value: null },
{ label: "Check", onclick: () => save(true) },
{ label: r.id ? "Save" : "Add rule", kind: "primary", onclick: () => save(false) },
], { wide: true, sticky: true });
async function save(dry) {
mount(err);
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "",
ct_state: states.value(), ct_status: statuses.value(), days: days.value(), hour_from: hourFrom.value.trim(), hour_to: hourTo.value.trim(),
limit: rate.value ? { rate: parseInt(rate.value, 10), per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, per_source: perSource.input.checked } : null,
conn_limit: parseInt(conn.value || "0", 10), log: log.input.checked, log_prefix: logPrefix.value.trim(), raw: raw.value.trim(), comment: comment.value.trim() };
if (r.id) v.disabled = disabled.input.checked;
try {
await change(r.id ? "PATCH" : "POST", r.id ? "/rules/" + r.id : "/rules", v, { query: dry ? { dry_run: 1 } : undefined });
if (dry) return false;
if (v.chain !== chain) location.hash = "#/rules?chain=" + v.chain;
else rerender();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
}
}
@@ -0,0 +1,49 @@
// Services: named ports and ICMP types, built in and yours.
import { h, mount, table, modal, errorBox, field, input, confirmDialog, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor } from "../widgets.js";
export async function render(el) {
const services = await get("/services");
const write = can("config", true);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Services"), h("div", { class: "muted" }, "Ports of a protocol and ICMP types, by name. The built-in ones can be changed by adding one of the same name.")),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => serviceDialog(null) }, "Add service")) : null),
h("div", { class: "panel" }, table([
{ label: "Name", render: (s) => h("strong", null, s.name) },
{ label: "Entries", render: (s) => h("span", { class: "mono small" }, s.entries.join(", ")) },
{ label: "", render: (s) => s.builtin ? badge("built in", "") : badge("yours", "accent") },
{ label: "Comment", render: (s) => s.comment },
write ? { label: "", tdClass: "actions-cell", render: (s) => s.builtin
? h("button", { class: "small reveal", onclick: () => serviceDialog({ name: s.name, entries: s.entries }) }, "Change")
: [h("button", { class: "small reveal", onclick: () => serviceDialog(s) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
].filter(Boolean), services)));
}
async function del(s) {
if (!await confirmDialog("Delete service", "Delete service " + s.name + "?", "Delete", "danger")) return;
try { await change("DELETE", "/services/" + s.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
}
async function serviceDialog(s) {
s = s || { entries: [] };
const name = input({ value: s.name || "", placeholder: "jellyfin" });
const list = listEditor(s.entries, { suggestions: ["tcp/", "udp/", "icmp/echo-request", "icmpv6/echo-request"], placeholder: "tcp/8096, udp/27015-27030, icmp/echo-request", addLabel: "Add entry" });
const comment = input({ value: s.comment || "" });
const err = h("div");
await modal(s.id ? "Edit service " + s.name : "Add service", h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Entries", list), field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: s.id ? "Save" : "Add service", kind: "primary", onclick: async () => {
mount(err);
try {
await change(s.id ? "PATCH" : "POST", s.id ? "/services/" + s.id : "/services", { name: name.value.trim(), entries: list.value(), comment: comment.value.trim() });
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { sticky: true });
}
+78
View File
@@ -0,0 +1,78 @@
// Address sets, and dynamic sets' entries.
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
import { get, api, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor, fmtSeconds, parseSeconds } from "../widgets.js";
export async function render(el) {
const sets = await get("/sets");
const write = can("config", true);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Address sets"), h("div", { class: "muted" }, "Named lists of IPv4 and IPv6 addresses, prefixes and ranges, mixed. A dynamic set takes entries at run time, each with a timeout (a captive portal's signed-in clients).")),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => setDialog(null) }, "Add set")) : null),
h("div", { class: "panel" }, table([
{ label: "Name", render: (s) => h("strong", null, s.name) },
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
{ label: "Comment", render: (s) => s.comment },
write ? { label: "", tdClass: "actions-cell", render: (s) => [
h("button", { class: "small reveal", onclick: () => setDialog(s) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
].filter(Boolean), sets, { empty: "No sets yet." })));
}
async function del(s) {
if (!await confirmDialog("Delete set", "Delete set " + s.name + "?", "Delete", "danger")) return;
try { await change("DELETE", "/sets/" + s.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
}
async function setDialog(s) {
s = s || { entries: [] };
const name = input({ value: s.name || "", placeholder: "trusted" });
const list = listEditor(s.entries, { placeholder: "10.0.10.0/24, 2001:db8:10::/64, 10.0.0.1-10.0.0.9", addLabel: "Add entry" });
const dynamic = checkbox("dynamic: entries are added at run time", s.dynamic);
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
const comment = input({ value: s.comment || "" });
const err = h("div");
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Entries", list), h("label"), dynamic, field("Entry timeout", timeout, "A dynamic set's default."), field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
mount(err);
const t = parseSeconds(timeout.value);
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
try {
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: list.value(), dynamic: dynamic.input.checked, timeout: t, comment: comment.value.trim() });
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true, sticky: true });
}
// entries shows and changes a dynamic set's entries.
async function entries(s) {
const body = h("div");
const write = can("sets", true);
const draw = async () => {
const es = await get("/sets/" + encodeURIComponent(s.name) + "/elements");
const addr = input({ placeholder: "10.0.30.5 or 2001:db8::5", class: "mono" });
const t = input({ placeholder: s.timeout ? fmtSeconds(s.timeout) : "none", class: "short" });
mount(body,
table([
{ label: "Address", render: (e) => h("span", { class: "mono" }, e.address) },
{ label: "Expires in", render: (e) => e.timeout ? fmtSeconds(e.timeout) : "never" },
write ? { label: "", render: (e) => h("button", { class: "small", onclick: async () => {
try { await api("DELETE", "/sets/" + encodeURIComponent(s.name) + "/elements/" + encodeURIComponent(e.address)); draw(); } catch (x) { toast(x.message, "err"); }
} }, "Remove") } : null,
].filter(Boolean), es, { empty: "No entries." }),
write ? h("div", { class: "filter-row" }, addr, t, h("button", { class: "primary small", onclick: async () => {
const secs = parseSeconds(t.value);
if (secs === null) { toast("Write the timeout as 1h, 30m or 1d", "err"); return; }
try { await api("POST", "/sets/" + encodeURIComponent(s.name) + "/elements", { body: { address: addr.value.trim(), timeout: secs } }); draw(); } catch (x) { toast(x.message, "err"); }
} }, "Add")) : null);
};
await draw();
await modal("Entries of " + s.name, body, [{ label: "Close", value: null }], { wide: true });
}
@@ -0,0 +1,63 @@
// Settings: policies, built-ins, antispoofing, flowtable, probation.
import { h, mount, field, select, checkbox, input, errorBox, readOnly } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor, fmtSeconds, parseSeconds } from "../widgets.js";
const builtinText = [
["loopback", "Accept everything on lo"],
["established", "Accept established and related connections (input and forward)"],
["drop_invalid", "Drop invalid packets (input and forward)"],
["icmpv6", "Accept the ICMPv6 IPv6 needs: errors, echo, neighbour discovery, MLD (RFC 4890)"],
["ping_internal", "Accept ping to the firewall from internal zones"],
["dhcpv6_client", "Accept DHCPv6 replies on external zones (your ISP's prefix delegation)"],
["antispoof", "Drop impossible sources arriving on external zones (below)"],
["mss_clamp", "Clamp TCP MSS to the route MTU on external zones (needed with PPPoE)"],
];
export async function render(el) {
const [s, ifs] = await Promise.all([get("/settings"), get("/interfaces")]);
const write = can("config", true);
const pol = (v) => select([["drop", "drop"], ["accept", "accept"]], v);
const input_ = pol(s.input_policy), forward = pol(s.forward_policy), output = pol(s.output_policy);
const logDrops = checkbox("log what the input and forward policies drop (rate-limited; the Log page)", s.log_policy_drops);
const bis = builtinText.map(([k, text]) => [k, checkbox(text, s.builtins[k])]);
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8:7f3d::/48", addLabel: "Add prefix" });
const v4 = listEditor(s.antispoof_v4, { addLabel: "Add" });
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
const err = h("div");
const save = async (dry) => {
mount(err);
const p = parseSeconds(probation.value);
if (p === null) { mount(err, h("div", { class: "msg err" }, "Write the probation as 2m or 90s.")); return; }
const builtins = {};
for (const [k, c] of bis) builtins[k] = c.input.checked;
try {
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and built-ins"),
h("div", { class: "muted" }, "What happens to packets no rule decided, and the rules Housecarl adds before yours."))),
h("div", { class: "panel" }, h("div", { class: "form-grid" },
field("Input policy", input_, "Traffic to the firewall itself."),
field("Forward policy", forward, "Traffic through it."),
field("Output policy", output, "Traffic from it."),
h("label"), logDrops,
field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))),
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
field("Antispoof IPv4", v4),
field("Antispoof IPv6", v6),
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path). Exact names; none: off."),
field("Probation", probation, "How long a change made here has to be confirmed before it is rolled back.")),
err,
write ? h("div", { class: "actions form-actions" },
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
if (!write) readOnly(el.querySelector(".panel"));
}
+121
View File
@@ -0,0 +1,121 @@
// Users and everyone's tokens (administrators).
import { h, mount, table, time, fmtTime, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge, copyBox } from "../ui.js";
import { get, api, catalog } from "../api.js";
import { rerender } from "../app.js";
export async function render(el) {
const [users, tokens, cat] = await Promise.all([get("/users"), get("/tokens"), catalog()]);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Users"), h("div", { class: "muted" }, "Administrators may do everything. Others get a role or grants: config, sets, log and audit, each read or write; a sets grant can name the sets it covers (sets:write:guests).")),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, cat) }, "New user"))),
h("div", { class: "panel" }, table([
{ label: "User", render: (u) => [h("strong", null, u.username), u.disabled ? [" ", badge("disabled", "off")] : null] },
{ label: "May", render: (u) => u.admin ? badge("administrator", "accent") : h("span", { class: "mono small" }, u.grant_strings.join(" ") || "nothing") },
{ label: "Password", render: (u) => u.has_password ? "yes" : h("span", { class: "muted" }, "tokens only") },
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
{ label: "Comment", render: (u) => u.comment },
{ label: "", tdClass: "actions-cell", render: (u) => [
h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
], users)),
h("h2", null, "API tokens"),
h("div", { class: "panel" }, table([
{ label: "User", render: (t) => t.username },
{ label: "Name", render: (t) => t.name },
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), h("span", { class: "muted small" }, " from " + t.last_used_ip)] : "never" },
{ label: "", tdClass: "actions-cell", render: (t) => [
h("button", { class: "small reveal", onclick: () => rotateDialog(t) }, "Rotate"), " ",
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
], tokens, { empty: "No tokens." })));
}
// rotateDialog gives a token a new secret. It keeps its name, ID and
// permissions; only the programs that use it need the new secret.
export async function rotateDialog(t) {
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
if (t.expires_at) expOpts.push(["never", "never expires"]);
const exp = select(expOpts, "");
const err = h("div");
const box = h("div");
const go = h("button", { class: "primary", onclick: async () => {
mount(err);
const body = {};
if (grace.value) body.grace = grace.value;
if (exp.value === "never") body.no_expiry = true;
else if (exp.value) body.expires_in = exp.value;
go.disabled = true;
try {
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
mount(box, h("div", { class: "msg ok" },
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
copyBox(r.secret),
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), "."] : "The old secret no longer works.")));
rerender();
} catch (ex) { go.disabled = false; mount(err, errorBox(ex)); }
} }, "Rotate");
mount(box,
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
h("div", { class: "form-grid" },
field("Old secret", grace, "A grace period lets you update the programs without an outage. If the token may have leaked, let it stop now."),
field("Expires", exp)),
err, h("div", { class: "actions mt" }, go));
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
export async function revoke(t) {
if (!await confirmDialog("Revoke token", "Revoke " + t.username + "'s token “" + t.name + "”? Anything using it is refused from now on.", "Revoke", "danger")) return;
try {
await api("DELETE", "/tokens/" + t.id);
toast("Revoked");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function del(u) {
if (!await confirmDialog("Delete user", "Delete " + u.username + " and their tokens?", "Delete", "danger")) return;
try {
await api("DELETE", "/users/" + u.id);
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function userDialog(u, cat) {
const name = input({ value: u ? u.username : "", autocomplete: "off" });
const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "unchanged" : "at least 8 characters" });
const admin = checkbox("Administrator", u ? u.admin : false);
const role = select([["", "custom grants"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
const grants = input({ value: u ? u.grant_strings.join(" ") : "", placeholder: "config:read log:read sets:write:guests", class: "mono" });
const disabled = checkbox("Disabled (logs them out)", u ? u.disabled : false);
const comment = input({ value: u ? u.comment : "" });
const err = h("div");
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
field("Username", name),
field("Password", pw, u ? "Leave empty to keep it." : "Leave empty for a service account that only uses API tokens."),
h("label"), admin,
field("Role", role),
field("Grants", grants, "resource:access, separated by spaces; resources config, sets, log, audit; access read or write; sets:write:NAME,NAME for some sets only."),
u ? [h("label"), disabled] : null,
field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: u ? "Save" : "Add user", kind: "primary", onclick: async () => {
mount(err);
const body = { username: name.value.trim(), admin: admin.input.checked, comment: comment.value };
if (role.value) body.role = role.value;
else body.grants = grants.value.split(/[\s,]+/).filter(Boolean);
if (pw.value || !u) body.password = pw.value;
if (u) body.disabled = disabled.input.checked;
try {
await api(u ? "PATCH" : "POST", u ? "/users/" + u.id : "/users", { body });
rerender();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
], { sticky: true });
}
+55
View File
@@ -0,0 +1,55 @@
// Zones: groups of interfaces, and the machine's interfaces.
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor } from "../widgets.js";
export async function render(el) {
const [zones, ifs] = await Promise.all([get("/zones"), get("/interfaces")]);
const write = can("config", true);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, "Groups of interfaces that rules name. External zones face the internet: antispoofing, MSS clamping and port forwards apply there.")),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => zoneDialog(null, ifs) }, "Add zone")) : null),
h("div", { class: "panel" }, table([
{ label: "Name", render: (z) => h("strong", null, z.name) },
{ label: "Interfaces", render: (z) => h("span", { class: "mono" }, z.interfaces.join(", ")) },
{ label: "", render: (z) => z.external ? badge("external", "warn") : badge("internal", "") },
{ label: "Comment", render: (z) => z.comment },
write ? { label: "", tdClass: "actions-cell", render: (z) => [
h("button", { class: "small reveal", onclick: () => zoneDialog(z, ifs) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(z) }, "Delete")] } : null,
].filter(Boolean), zones, { empty: "No zones yet: add one per network (wan, lan, guest…)." })),
h("div", { class: "panel" }, h("h2", null, "This machine's interfaces"), table([
{ label: "Interface", render: (i) => h("span", { class: "mono" }, i.name) },
{ label: "State", render: (i) => badge(i.up ? "up" : "down", i.up ? "ok" : "") },
{ label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") },
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ")) },
], ifs)));
}
async function del(z) {
if (!await confirmDialog("Delete zone", "Delete zone " + z.name + "?", "Delete", "danger")) return;
try { await change("DELETE", "/zones/" + z.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
}
async function zoneDialog(z, ifs) {
z = z || { interfaces: [] };
const name = input({ value: z.name || "", placeholder: "wan, lan, guest" });
const list = listEditor(z.interfaces, { suggestions: ifs.map((i) => i.name), placeholder: "eth0, or incusbr* for many", addLabel: "Add interface" });
const ext = checkbox("external: faces the internet", z.external);
const comment = input({ value: z.comment || "" });
const err = h("div");
await modal(z.id ? "Edit zone " + z.name : "Add zone", h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Interfaces", list, "A trailing * matches many (incusbr*)."), h("label"), ext, field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: z.id ? "Save" : "Add zone", kind: "primary", onclick: async () => {
mount(err);
try {
await change(z.id ? "PATCH" : "POST", z.id ? "/zones/" + z.id : "/zones", { name: name.value.trim(), interfaces: list.value(), external: ext.input.checked, comment: comment.value.trim() });
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { sticky: true });
}
+312 -6
View File
@@ -1,5 +1,5 @@
/* Housecarl web UI. Plain CSS, light and dark themes (the same as Jarl's and
Steward's). For now only the colors and the brand: the pages come later. */
Steward's). */
:root {
--bg: #f6f7f9;
--panel: #ffffff;
@@ -65,6 +65,304 @@
--accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326;
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
--shadow: none; color-scheme: dark;
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, .mono, textarea.mono, input.mono { font-family: var(--mono); font-size: 13px; }
pre { background: var(--code-bg); padding: 10px 12px; border-radius: var(--radius); overflow: auto; margin: 0; white-space: pre; }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 18px 0 8px; }
h3 { font-size: 14px; margin: 14px 0 6px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.nowrap { white-space: nowrap; }
.warn-text { color: var(--warn); }
.right { text-align: right; }
.hidden { display: none !important; }
/* Layout */
header.top {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 8px 16px; position: sticky; top: 0; z-index: 10;
}
header.top .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; }
header.top .brand .logo { display: block; transition: transform 0.15s; }
header.top .brand .brand-name { display: block; color: var(--text); }
header.top .brand:hover { text-decoration: none; }
header.top .brand:hover .brand-name { color: var(--accent); }
header.top .brand:hover .logo { transform: scale(1.06); }
nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
nav.main a:hover { background: var(--info-soft); text-decoration: none; }
nav.main a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
header.top .spacer { flex: 1; }
header.top form.search input { width: 220px; }
header.top .user { color: var(--muted); }
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.page-head > .actions { padding: 4px 0; }
.filter-row + .panel, .actions + .panel { margin-top: 4px; }
.page-head .grow { flex: 1; min-width: 200px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* Panels and cards */
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
/* Tiles share a row and grow to fill it, so a wrapped tile never sits
alone beside an empty space. */
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
.cards > * { flex: 1 1 170px; min-width: 0; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
.card .sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; background: var(--panel); }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
tr:hover td { background: var(--info-soft); }
td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; }
td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
tr.editing td { background: var(--accent-soft); }
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
/* Disabled records: hatched, so they stand out from live and pending rows. */
tr.disabled td { background-image: repeating-linear-gradient(135deg, transparent 0 7px, var(--hatch) 7px 9px); }
.badge.off { background: var(--muted); color: var(--panel); }
button.toggle { min-width: 5.2em; }
.empty { color: var(--muted); padding: 18px; text-align: center; }
/* Forms */
input, select, textarea, button {
font: inherit; color: var(--text);
}
input[type=text], input[type=password], input[type=number], input[type=search], input:not([type]), select, textarea {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; min-width: 0;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
textarea { width: 100%; min-height: 80px; resize: vertical; }
.form-grid { display: grid; grid-template-columns: minmax(140px, 200px) 1fr; gap: 8px 14px; align-items: start; }
.form-grid > label { padding-top: 6px; color: var(--muted); }
.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; }
.form-grid input, .form-grid select { width: 100%; max-width: 560px; }
.form-grid input[type=checkbox] { width: auto; }
.inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
.inline-form .f label:not(.check) { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }
.inline-form .grow { flex: 1; min-width: 220px; }
.inline-form .grow input { width: 100%; }
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
button, .button {
display: inline-flex; align-items: center; gap: 6px;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 5px 12px; cursor: pointer; white-space: nowrap; color: var(--text);
}
button:hover, .button:hover { background: var(--info-soft); text-decoration: none; }
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
button.primary:hover { filter: brightness(1.08); background: var(--accent); }
button.danger { color: var(--err); border-color: var(--err); }
button.danger:hover { background: var(--err-soft); }
button.small, .button.small { padding: 2px 8px; font-size: 12px; }
button.link { border: none; background: none; color: var(--accent); padding: 0 4px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Badges and messages */
.badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; background: var(--info-soft); color: var(--muted); white-space: nowrap; }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.err { background: var(--err-soft); color: var(--err); }
.badge.accent { background: var(--accent-soft); color: var(--accent); }
.msg { border-radius: var(--radius); padding: 8px 12px; margin: 8px 0; border: 1px solid transparent; }
.msg.err { background: var(--err-soft); color: var(--err); border-color: var(--err); }
.msg.warn { background: var(--warn-soft); color: var(--warn); }
.msg.ok { background: var(--ok-soft); color: var(--ok); }
.msg.info { background: var(--info-soft); }
.msg ul { margin: 4px 0 0; padding-left: 18px; }
.problems { list-style: none; padding: 0; margin: 6px 0; }
.problems li { padding: 4px 8px; border-left: 3px solid var(--border); margin-bottom: 3px; background: var(--info-soft); border-radius: 0 var(--radius) var(--radius) 0; }
.problems li.error { border-color: var(--err); background: var(--err-soft); }
.problems li.warning { border-color: var(--warn); background: var(--warn-soft); }
.problems li .where { color: var(--muted); font-size: 12px; margin-left: 6px; }
/* Diff */
/* Tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 12px; flex-wrap: wrap; }
.tabs a { padding: 7px 12px; border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; color: var(--muted); }
.tabs a.active { background: var(--panel); border-color: var(--border); color: var(--text); font-weight: 600; margin-bottom: -1px; }
/* Modal */
.modal-back { position: fixed; inset: 0; background: rgba(10, 15, 25, 0.45); display: flex; align-items: flex-start; justify-content: center; padding: 60px 16px; z-index: 50; overflow: auto; }
.modal { background: var(--panel); border-radius: 8px; border: 1px solid var(--border); width: min(760px, 100%); padding: 18px 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.25); }
.modal.wide { width: min(1100px, 100%); }
.modal h2 { margin-top: 0; }
.modal .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* Toasts */
#toasts { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; max-width: min(480px, calc(100% - 32px)); }
.toast { background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent); padding: 10px 14px; border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
.toast.ok { border-left-color: var(--ok); }
.toast.err { border-left-color: var(--err); }
.toast.warn { border-left-color: var(--warn); }
/* Login */
.login { max-width: 360px; margin: 10vh auto; }
.login-logo { display: block; margin: 8px auto 10px; }
.login h1 { text-align: center; margin: 0 0 8px; }
.wordmark { display: block; width: 220px; height: auto; margin: 0 auto; color: var(--text); }
.wordmark-rule .fill { fill: var(--accent); }
.wordmark-rule .line { fill: none; stroke: var(--accent); stroke-width: 2.6; }
.login .tag { text-align: center; color: var(--muted); margin-bottom: 18px; }
.login form { display: flex; flex-direction: column; gap: 10px; }
.login input { width: 100%; padding: 8px 10px; }
.login button { justify-content: center; padding: 8px; }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; }
.grants { font-family: var(--mono); font-size: 12px; }
.copy-box { display: flex; gap: 6px; align-items: center; }
.login.wide { max-width: 520px; }
.copy-box input { flex: 1; font-family: var(--mono); }
.copy-box input { min-width: 0; }
td.break { word-break: break-all; }
@media (max-width: 700px) {
.form-grid { grid-template-columns: 1fr; }
.form-grid .help { grid-column: 1; }
header.top form.search input { width: 140px; }
}
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
/* Secondary row actions show when the row is hovered or focused; touch
screens, which can't hover, always show them. Opacity keeps them in the
layout (no shifting) and reachable with Tab. */
tr .reveal { opacity: 0; transition: opacity 0.1s; }
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
@media (hover: none) { tr .reveal { opacity: 1; } }
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
.segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; }
.segmented a:last-child { border-right: none; }
.segmented a:hover { background: var(--info-soft); }
.segmented a.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
/* the "← Zones" link above a page's heading */
.back-link { font-size: 13px; margin-bottom: 2px; }
tr.selected td { background: var(--accent-soft); }
/* the page footer with the Housecarl build */
.shell { min-height: 100vh; display: flex; flex-direction: column; }
.shell > main { flex: 1; width: 100%; }
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }
/* a request in progress */
.spinner { display: inline-block; width: 11px; height: 11px; margin-right: 6px; vertical-align: -1px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-top-color: var(--border); background: var(--accent-soft); } }
.err-text { color: var(--err); }
ul.plain { list-style: none; margin: 8px 0; padding: 0; }
ul.plain li { padding: 3px 0; }
/* Drop-down menus in the top bar (Server, the user). */
details.menu { position: relative; }
details.menu > summary { list-style: none; cursor: pointer; padding: 6px 10px; border-radius: var(--radius); color: var(--text); white-space: nowrap; }
details.menu > summary::-webkit-details-marker { display: none; }
details.menu > summary:hover, details.menu[open] > summary { background: var(--info-soft); }
details.menu > summary.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
details.menu > summary .caret { margin-left: 4px; font-size: 0.8em; opacity: 0.7; }
details.menu > summary .user { color: inherit; }
details.menu .menu-list {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
display: flex; flex-direction: column; padding: 4px;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
details.menu.right .menu-list { left: auto; right: 0; }
details.menu .menu-list a, details.menu .menu-list .menu-item {
display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text); text-align: left;
background: none; border: 0; font: inherit; cursor: pointer; white-space: nowrap;
}
details.menu .menu-list a:hover, details.menu .menu-list .menu-item:hover { background: var(--info-soft); text-decoration: none; }
details.menu .menu-list a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
nav.main { align-items: center; }
header.top .header-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
@media (max-width: 1200px) {
header.top form.search input { width: 160px; }
header.top { gap: 10px; }
}
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
/* ---- Housecarl */
.wordmark-rule .tag { fill: var(--accent); font: 700 17px system-ui, sans-serif; letter-spacing: 0.5px; }
.login .wordmark { width: 290px; }
.subnet-usage { width: 260px; }
/* How full a subnet's pools are. */
.usage { min-width: 120px; }
.usage-bar { display: flex; height: 8px; border-radius: 4px; background: var(--info-soft); overflow: hidden; border: 1px solid var(--border); margin-top: 3px; }
.usage-bar .seg.leased { background: var(--accent); }
.usage-bar .seg.declined { background: var(--warn); }
.usage-bar.full .seg.leased { background: var(--err); }
.usage-text { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
/* Interfaces: serving or not. */
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; background: var(--muted); }
.dot.ok { background: var(--ok); }
.dot.err { background: var(--err); }
.iface-list { display: flex; flex-direction: column; gap: 6px; }
.iface-list .iface { display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline; }
.iface-list .iface .name { font-family: var(--mono); font-weight: 600; min-width: 8ch; }
/* The options editor: a row per option. */
.opt-rows { display: flex; flex-direction: column; gap: 6px; }
.opt-row { display: flex; gap: 6px; align-items: flex-start; flex-wrap: wrap; }
.opt-row select { width: 15em; }
.opt-row input.value { flex: 1; min-width: 14em; font-family: var(--mono); }
.opt-row select.type { width: 8em; }
.opt-row .help { flex-basis: 100%; color: var(--muted); font-size: 12px; margin: -2px 0 2px; }
.opt-inherited { color: var(--muted); font-size: 12px; margin-top: 6px; }
.opt-inherited code { font-family: var(--mono); }
/* Pools editor. */
.pool-rows { display: flex; flex-direction: column; gap: 6px; }
.pool-row { display: flex; gap: 6px; align-items: center; }
.pool-row input { font-family: var(--mono); width: 18em; }
.pool-row select { width: auto; }
/* Activity: one line per message. */
table.activity td { white-space: nowrap; }
table.activity td.detail { white-space: normal; color: var(--muted); }
table.activity tr.event-details > td { white-space: normal; background: var(--bg); }
.event-detail table.kv { border-collapse: collapse; margin: 4px 0; }
.event-detail table.kv th { text-align: left; font-weight: normal; color: var(--muted); padding: 2px 16px 2px 0; vertical-align: top; white-space: nowrap;
text-transform: none; letter-spacing: normal; font-size: inherit; background: none; border: 0; }
.event-detail table.kv td { padding: 2px 0; white-space: normal; word-break: break-word; background: none; border: 0; }
.type-OFFER, .type-ACK { color: var(--ok); }
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
.type-RELEASE { color: var(--muted); }
td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
.mt { margin-top: 10px; }
input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; }
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.dimmed { opacity: 0.55; }
/* The logo's own blue (artwork/logo.svg), lighter on dark backgrounds. */
:root { --logo: #1d5287; }
@@ -72,8 +370,16 @@
:root[data-theme="dark"] { --logo: #6d9be8; }
.logo-mark { fill: var(--logo); }
.wordmark { display: block; width: 220px; height: auto; margin: 0 auto; color: var(--text); }
.wordmark-rule .fill { fill: var(--accent); }
.wordmark-rule .line { fill: none; stroke: var(--accent); stroke-width: 2.6; }
.wordmark-rule .tag { fill: var(--accent); font: 700 17px system-ui, sans-serif; letter-spacing: 0.5px; }
.login .wordmark { width: 290px; }
/* The probation banner: changes roll back unless confirmed. */
.probation-banner { background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--warn); padding: 10px 24px; font-size: 14px; }
.probation-banner .banner-actions { margin-left: 12px; white-space: nowrap; }
/* List editors (zones, addresses, services). */
.list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
.list-row { display: flex; gap: 6px; align-items: center; }
.list-row input { width: 26em; max-width: 100%; }
input.short { width: 7em; }
.builtins { margin-bottom: 10px; line-height: 1.6; }
.policy { margin-top: 10px; }
.checks { display: flex; flex-direction: column; gap: 4px; }
.form-actions { margin-top: 14px; }
tr.disabled td:not(.actions-cell) { opacity: 0.55; }
+236
View File
@@ -0,0 +1,236 @@
// Small DOM helpers and widgets used by every page.
// h creates an element. attrs may contain class, style, on<event>
// handlers, dataset, value/checked/disabled and any other attribute.
export function h(tag, attrs, ...children) {
const el = document.createElement(tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
if (v === undefined || v === null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
else if (k === "dataset") Object.assign(el.dataset, v);
else if (k === "value") el.value = v;
else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v;
else el.setAttribute(k, v === true ? "" : v);
}
}
append(el, children);
return el;
}
function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el) {
while (el.firstChild) el.removeChild(el.firstChild);
return el;
}
export function mount(el, ...children) {
clear(el);
append(el, children);
return el;
}
// ---- formatting
export function fmtTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
}
export function relTime(iso) {
if (!iso) return "";
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
const s = Math.abs(diff);
if (s < 60) return "just now";
let n;
if (s < 3600) n = Math.floor(s / 60) + " min";
else if (s < 86400) n = Math.floor(s / 3600) + " h";
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
else return fmtTime(iso);
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
}
export function time(iso) {
return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso));
}
export function plural(n, word, words) {
return n + " " + (n === 1 ? word : (words || word + "s"));
}
export function badge(text, kind) {
return h("span", { class: "badge" + (kind ? " " + kind : "") }, text);
}
// ---- toasts
export function toast(message, kind = "ok", ms = 3500) {
const box = document.getElementById("toasts");
const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message);
// A repeated message replaces the earlier one instead of stacking.
for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove();
box.append(t);
while (box.children.length > 3) box.firstChild.remove();
setTimeout(() => t.remove(), ms);
}
// ---- modal dialogs
// modal shows a dialog. buttons: [{label, kind, value, onclick}].
// onclick may return false to keep the dialog open, or a promise.
export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) {
return new Promise((resolve) => {
const back = h("div", { class: "modal-back" });
const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
const onKey = (e) => { if (e.key === "Escape") close(null); };
document.addEventListener("keydown", onKey);
const btns = buttons.map((b) =>
h("button", {
class: b.kind || "", type: "button",
onclick: async (e) => {
if (b.onclick) {
e.target.disabled = true;
try {
const r = await b.onclick();
if (r === false) return;
} finally { e.target.disabled = false; }
}
close(b.value === undefined ? b.label : b.value);
},
}, b.label));
const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns));
back.append(dialog);
back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); });
document.body.append(back);
const first = dialog.querySelector("input, select, textarea");
if (first) first.focus();
});
}
export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") {
const r = await modal(title, h("p", null, message), [
{ label: "Cancel", value: false },
{ label: okLabel, kind, value: true },
]);
return r === true;
}
// ---- forms
// field returns a label + control pair for .form-grid layouts.
export function field(label, control, help) {
// The label points at the control itself, also when it is wrapped.
const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
target.id = id;
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
}
export function input(attrs = {}) {
return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs });
}
export function select(options, value, attrs = {}) {
const s = h("select", attrs);
for (const o of options) {
const [v, label] = Array.isArray(o) ? o : [o, o];
s.append(h("option", { value: v, selected: String(v) === String(value) }, label));
}
return s;
}
export function textarea(value, attrs = {}) {
return h("textarea", { spellcheck: "false", ...attrs, value: value || "" });
}
export function checkbox(label, checked, attrs = {}) {
const c = h("input", { type: "checkbox", checked, ...attrs });
const l = h("label", { class: "check" }, c, label);
l.input = c;
return l;
}
// lines splits a textarea's value into non-empty trimmed lines.
export function lines(text) {
return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== "");
}
// ---- tables
// table renders rows with columns [{label, render(row) => node|string, class}].
export function table(columns, rows, opts = {}) {
if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet.");
const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label))));
const tbody = h("tbody");
rows.forEach((row, i) => {
const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" });
for (const c of columns) {
const v = c.render(row, i);
tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v));
}
tbody.append(tr);
});
return h("div", { class: "table-wrap" }, h("table", null, thead, tbody));
}
// ---- problems and errors
export function problemsList(problems) {
if (!problems || !problems.length) return null;
return h("ul", { class: "problems" }, problems.map((p) =>
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
}
// errorBox shows an API error with its problems.
export function errorBox(err) {
if (!err) return null;
const d = err.details || {};
let problems = d.problems || [];
// Don't repeat a single problem that is already the headline.
if (problems.length === 1 && err.message.includes(problems[0].message)) problems = [];
return h("div", { class: "msg err" }, h("div", null, err.message), problemsList(problems));
}
export function copyBox(value) {
const inp = input({ value, readOnly: true });
const btn = h("button", { type: "button", class: "small", onclick: async () => {
try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); }
} }, "Copy");
return h("div", { class: "copy-box" }, inp, btn);
}
// debounce delays fn until calls stop for ms.
export function debounce(fn, ms = 250) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
// backLink is the "← Zones" link above a page's heading, back to the
// page it was opened from.
export function backLink(label, href) {
return h("nav", { class: "back-link", "aria-label": "Back" }, h("a", { href }, "← ", label));
}
// safeBack returns a "back" address taken from the URL if it is one of
// the UI's own (#/...), else null: it ends up in an href.
export function safeBack(v) {
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
}
// readOnly locks a form for someone who may look but not change: every
// control is disabled, and the editors' add and remove buttons hidden.
export function readOnly(el) {
for (const c of el.querySelectorAll("input, select, textarea")) c.disabled = true;
for (const b of el.querySelectorAll("button")) b.classList.add("hidden");
return el;
}
+134
View File
@@ -0,0 +1,134 @@
// Widgets shared by several pages: list editors with suggestions, the
// match editor of rules and NAT, descriptions, hit counts, durations.
import { h, input, select, field, checkbox } from "./ui.js";
import { get } from "./api.js";
// ---- durations
// fmtSeconds writes seconds the short way: 43200 is "12h", 5400 "1h30m".
export function fmtSeconds(n) {
if (!n || n <= 0) return "";
const parts = [];
for (const [s, u] of [[86400, "d"], [3600, "h"], [60, "m"], [1, "s"]]) {
if (n >= s) { parts.push(Math.floor(n / s) + u); n %= s; }
}
return parts.join("");
}
// parseSeconds reads "12h", "30m", "1d12h" or plain seconds; null when
// it can't.
export function parseSeconds(s) {
s = String(s || "").trim();
if (s === "") return 0;
if (/^\d+$/.test(s)) return parseInt(s, 10);
const re = /(\d+)\s*([dhms])/g;
let total = 0, m, used = "";
while ((m = re.exec(s))) {
total += parseInt(m[1], 10) * { d: 86400, h: 3600, m: 60, s: 1 }[m[2]];
used += m[0];
}
return used.replace(/\s/g, "") === s.replace(/\s/g, "") && total > 0 ? total : null;
}
// ---- names for suggestions
// names loads the zones, sets and services (with the built-in ones), for
// the editors' suggestions.
export async function names() {
const [zones, sets, services] = await Promise.all([get("/zones"), get("/sets"), get("/services")]);
return { zones: zones.map((z) => z.name), sets: sets.map((s) => s.name), services: services.map((s) => s.name), zoneList: zones, setList: sets };
}
let listID = 0;
// listEditor edits a list of strings, one per row, with suggestions.
// value() returns the non-empty entries.
export function listEditor(values, opts = {}) {
const rows = h("div", { class: "list-rows" });
const id = "dl" + (++listID);
const datalist = h("datalist", { id }, (opts.suggestions || []).map((s) => h("option", { value: s })));
let items = [];
const add = (v) => {
const it = { input: input({ value: v || "", placeholder: opts.placeholder || "", list: id, class: opts.mono === false ? "" : "mono" }) };
it.row = h("div", { class: "list-row" }, it.input,
h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); items = items.filter((x) => x !== it); } }, "✕"));
items.push(it);
rows.append(it.row);
return it.input;
};
for (const v of values || []) add(v);
const box = h("div", { class: "list-editor" }, rows, datalist,
h("button", { type: "button", class: "small", onclick: () => add("").focus() }, "+ " + (opts.addLabel || "Add")));
box.value = () => items.map((it) => it.input.value.trim()).filter((v) => v !== "");
return box;
}
// ---- matches
// matchEditor edits what a rule, source NAT or redirect matches. It
// returns the form-grid fields; value() is the match's fields.
export function matchEditor(m, n, opts = {}) {
m = m || {};
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 10.0.10.0/24, 2001:db8::/64, 10.0.0.1-10.0.0.9", addLabel: "Add" });
const inZ = zones(m.in_zones), outZ = zones(m.out_zones);
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
const src = addrs(m.sources), dst = addrs(m.destinations);
const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", m.not_destinations);
const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" });
const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || "");
const fields = [
opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null,
opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null,
field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
field("To", h("div", null, dst, notDst)),
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
field("Family", fam),
];
fields.value = () => {
const v = { sources: src.value(), not_sources: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked,
services: svc.value(), family: fam.value };
if (opts.in !== false) Object.assign(v, { in_zones: inZ.value(), not_in: notIn.input.checked });
if (opts.out !== false) Object.assign(v, { out_zones: outZ.value(), not_out: notOut.input.checked });
return v;
};
return fields;
}
// describe sums up a match: "in lan · from trusted · ssh, dns".
export function describe(m) {
const parts = [];
const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); };
side("in", m.not_in, m.in_zones);
side("out", m.not_out, m.out_zones);
side("from", m.not_sources, m.sources);
side("to", m.not_destinations, m.destinations);
if (m.services && m.services.length) parts.push(m.services.join(", "));
if (m.family) parts.push(m.family + " only");
return parts.length ? parts.join(" · ") : "everything";
}
// ---- hits
export function humanCount(n) {
if (n >= 1e10) return Math.floor(n / 1e9) + "G";
if (n >= 1e7) return Math.floor(n / 1e6) + "M";
if (n >= 1e4) return Math.floor(n / 1e3) + "k";
return String(n);
}
// hits shows an object's packets (marks add up), with bytes as a title.
export function hits(counters, ...marks) {
let p = 0, b = 0, found = false;
for (const m of marks) {
const c = counters && counters[m];
if (c) { p += c.packets; b += c.bytes; found = true; }
}
if (!found) return h("span", { class: "muted" }, "–");
return h("span", { class: "mono", title: p + " packets, " + b + " bytes" }, humanCount(p));
}
// counters loads the hit counters ({} without log:read).
export async function counters() {
try { return (await get("/counters")).counters; } catch { return {}; }
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Housecarl</title>
<link rel="icon" href="/assets/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/style.css">
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<noscript><p>Housecarl's web interface needs JavaScript. The REST API and the <code>hc</code> CLI work without it.</p></noscript>
<div id="app"></div>
<div id="toasts"></div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
// Package web embeds the web UI: a small single-page application that
// uses Housecarl's REST API for everything it does.
package web
import (
"embed"
"io/fs"
"mime"
)
func init() {
// Not in Go's built-in table, and the server sends nosniff.
mime.AddExtensionType(".woff2", "font/woff2")
}
//go:embed static
var static embed.FS
// FS returns the web UI files (index.html and assets/).
func FS() fs.FS {
sub, err := fs.Sub(static, "static")
if err != nil {
panic(err)
}
return sub
}