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