Staged undo, built-ins window, machine warnings; raw and dynamic set fixes

- Undo is staged like any edit: in review mode the web UI's Undo, and hc
  history undo with -C or HOUSECARL_CHANGESET, add a revert operation
  that checks, when replayed, that nothing it touches changed since;
  applied, History links the entries. Imports and takeovers still can't
  be staged.
- The rules page sums up a chain's built-ins in one line; a window lists
  them with what they do and their hits, and switches them.
- Warnings about the machine (dashboard, hc status): nftables.service
  enabled (it loads /etc/nftables.conf at boot and flushes every table
  when stopped), and the imported table still in force beside
  Housecarl's. The docs say to disable nftables.service but not stop it.
- A raw rule with other fields set is refused (raw_conflict) instead of
  silently ignoring them; the rule dialog's nftables section hides what
  it replaces, and the importer's raw rules carry only their text.
- A dynamic set with configured entries is refused
  (set_dynamic_entries): they were silently ignored. The set dialog
  shows entries or the entry timeout, as fits.
- The rule dialog's chain choices are just the chains' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 14:52:55 +01:00
1 parent d25e12995e
commit 9a165cf70b
26 files changed
+408 -58

No files matched your search

+87 -14
View File
@@ -31,17 +31,14 @@ export async function render(el, params, query) {
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]);
const actionBadge = (r) => r.raw ? badge("as written", "") : 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,
builtinsRow(chain, settings, cs),
table([
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
@@ -73,6 +70,63 @@ function countsTraffic(r) {
return !r.raw && (!!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit)));
}
// What each built-in does (a switch covers all its rules).
const builtinText = {
loopback: "Accept everything on lo (the firewall talking to itself)",
established: "Accept established and related connections: replies, and the rest of connections a rule let in",
drop_invalid: "Drop invalid packets: what connection tracking can't place",
icmpv6: "Accept the ICMPv6 that 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 sources that can't come from outside, on external zones (Settings: the lists and your prefixes)",
mss_clamp: "Clamp TCP's MSS to the route's MTU on external zones (needed with PPPoE)",
};
// builtinsRow sums up the chain's built-ins in one line, with a button to
// see and switch them.
function builtinsRow(chain, settings, cs) {
const list = builtins[chain] || [];
const on = list.filter(([flag]) => flag === "" || settings.builtins[flag]);
if (!list.length) return null;
return h("div", { class: "builtins muted small" },
on.length ? (on.length === 1 ? "Housecarl adds 1 rule before yours. " : "Housecarl adds " + on.length + " rules before yours. ") : "Housecarl adds no rules before yours here. ",
h("button", { class: "small", onclick: () => builtinsDialog(chain, settings, cs) }, "Built-ins…"));
}
// builtinsDialog lists the chain's built-ins: what each does, its hits,
// and a switch (a setting, so it covers every chain the built-in is in).
async function builtinsDialog(chain, settings, cs) {
const list = builtins[chain] || [];
const flags = [...new Set(list.map(([flag]) => flag))];
const write = can("config", true);
const boxes = {};
const rows = flags.map((flag) => {
const marks = list.filter(([f]) => f === flag).map(([, mark]) => mark).filter(Boolean);
const box = flag ? h("input", { type: "checkbox", checked: settings.builtins[flag], disabled: !write }) : null;
if (box) boxes[flag] = box;
return { flag, marks, box, text: flag ? builtinText[flag] : list.find(([f]) => f === flag)[2] };
});
const err = h("div");
const body = h("div", null,
h("p", { class: "muted" }, "Checked in this order, before your rules (rules with a quota or a byte rate come before the established built-in, so they see every packet). Each is a setting: switching one changes every chain it is in."),
table([
{ label: "On", render: (x) => x.box || h("span", { class: "muted small" }, "always") },
{ label: "Built-in", render: (x) => x.flag ? x.text : [x.text, h("div", { class: "muted small" }, "Part of each port forward.")] },
{ label: "Hits", render: (x) => x.marks.length ? hits(cs, ...x.marks.map((m) => "b-" + m)) : h("span", { class: "muted" }, "–") },
], rows),
err);
await modal("Built-ins of the " + chain + " chain", body, write ? [
{ label: "Cancel", value: null },
{ label: "Save", kind: "primary", onclick: async () => {
mount(err);
const b = {};
for (const [flag, box] of Object.entries(boxes)) if (box.checked !== settings.builtins[flag]) b[flag] = box.checked;
if (!Object.keys(b).length) return true;
try { await change("PATCH", "/settings", { builtins: b }); rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; }
} },
] : [{ label: "Close", value: null }], { wide: true });
}
function extras(r, q) {
const x = [];
if ((r.ct_state || []).length) x.push("state " + r.ct_state.join(","));
@@ -149,7 +203,7 @@ async function del(r) {
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 ch = select(["input", "forward", "output", "early"], 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 || "");
@@ -205,6 +259,7 @@ export async function ruleDialog(r, chain) {
sec.open = on;
for (const n of nodes) n.classList.toggle("hidden", !on);
add.classList.toggle("hidden", on);
if (sec.onShow) sec.onShow(on);
if (on) { const f = nodes.find((n) => n.querySelector && n.querySelector("input, select, textarea")); if (f) f.querySelector("input, select, textarea").focus(); }
updateGroup();
};
@@ -234,7 +289,7 @@ export async function ruleDialog(r, chain) {
field("Connection limit", conn, "Match a source that already has more than this many connections."),
]);
const rawFields = section("raw", "Write it in nftables", !!r.raw, [
field("Raw nftables", raw, "Instead of the matches above: one nftables rule, for what Housecarl can't say. Checked by nftables."),
field("Raw nftables", raw, "One nftables rule, for what Housecarl can't say, used as written: it replaces the zones, addresses, services, action, limits and log (write them in it). Checked by nftables."),
]);
// Grouping only means something with a per-address limit, quota or
// connection limit.
@@ -248,14 +303,30 @@ export async function ruleDialog(r, chain) {
limitKey.addEventListener("change", updateGroup);
quotaKey.addEventListener("change", updateGroup);
// Written in nftables, a rule is its text: the fields it replaces go
// (and the other parts close).
const matchNodes = match.flat().filter(Boolean);
const actionField = field("Action", h("div", null, action, " ", rejectWith)).filter(Boolean);
const logField = field("Log", h("div", null, log, logPrefix)).filter(Boolean);
const addersLabel = h("label");
sections.raw.onShow = (on) => {
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
for (const [k, sec] of Object.entries(sections)) {
if (k === "raw") continue;
if (on && sec.open) for (const n of sec.nodes) n.classList.add("hidden");
if (on) sec.open = false;
}
for (const b of adders.children) if (!b.textContent.includes("nftables")) b.classList.toggle("hidden", on);
updateGroup();
};
const body = h("div", null,
h("div", { class: "form-grid" },
field("Chain", ch),
...match.flat(),
field("Action", h("div", null, action, " ", rejectWith)),
...matchNodes,
...actionField,
...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields,
h("label"), adders,
field("Log", h("div", null, log, logPrefix)),
addersLabel, adders,
...logField,
field("Comment", comment),
r.id ? [h("label"), disabled] : null),
err);
@@ -270,7 +341,9 @@ export async function ruleDialog(r, chain) {
async function save(dry) {
mount(err);
const on = (k) => sections[k].open;
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "",
const isRaw = on("raw");
const m = isRaw ? { in_zones: [], not_in: false, out_zones: [], not_out: false, sources: [], not_sources: false, destinations: [], not_destinations: false, services: [], family: "" } : match.value();
const v = { chain: ch.value, ...m, action: isRaw ? "accept" : action.value, reject_with: !isRaw && action.value === "reject" ? rejectWith.value : "",
ct_state: on("state") ? states.value() : [], ct_status: on("state") ? statuses.value() : [],
days: on("time") ? days.value() : [], hour_from: on("time") ? hourFrom.value.trim() : "", hour_to: on("time") ? hourTo.value.trim() : "",
limit: on("rate") && rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked,
@@ -278,8 +351,8 @@ export async function ruleDialog(r, chain) {
quota: on("quota") && quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked,
per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination",
reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null,
group_v4: parseInt(group4.value || "0", 10), group_v6: parseInt(group6.value || "0", 10),
conn_limit: on("conn") ? parseInt(conn.value || "0", 10) : 0, log: log.input.checked, log_prefix: logPrefix.value.trim(),
group_v4: isRaw ? 0 : parseInt(group4.value || "0", 10), group_v6: isRaw ? 0 : parseInt(group6.value || "0", 10),
conn_limit: on("conn") ? parseInt(conn.value || "0", 10) : 0, log: !isRaw && log.input.checked, log_prefix: isRaw ? "" : logPrefix.value.trim(),
raw: on("raw") ? raw.value.trim() : "", comment: comment.value.trim() };
if (r.id) v.disabled = disabled.input.checked;
try {