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:
1 parent
d25e12995e
commit
9a165cf70b
26 files changed
+408
-58
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// 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 { get, change, can, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el, params) {
|
||||
@@ -20,10 +20,10 @@ export async function render(el, params) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too." + (!state.immediate ? " It is added to your pending changes, to review and apply." : ""), "Undo")) return;
|
||||
try {
|
||||
// An undo can't be staged: it is applied at once.
|
||||
await change("POST", "/history/" + e.id + "/revert", undefined, { immediate: true });
|
||||
// Like any edit: staged for review, or applied at once in live mode.
|
||||
await change("POST", "/history/" + e.id + "/revert");
|
||||
rerender();
|
||||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,19 +31,29 @@ async function setDialog(s) {
|
||||
s = s || { entries: [] };
|
||||
const name = input({ value: s.name || "", placeholder: "trusted" });
|
||||
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
|
||||
const dynamic = checkbox("dynamic: entries are added at run time", s.dynamic);
|
||||
const dynamic = checkbox("dynamic: entries are added at run time (the Entries button, hc set entries, a program with a token), each for a time", s.dynamic);
|
||||
// A dynamic set has no configured entries; only it has a timeout.
|
||||
const entryFields = field("Entries", list).filter(Boolean);
|
||||
const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean);
|
||||
const showKind = () => {
|
||||
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked);
|
||||
for (const n of timeoutFields) n.classList.toggle("hidden", !dynamic.input.checked);
|
||||
};
|
||||
dynamic.input.addEventListener("change", showKind);
|
||||
showKind();
|
||||
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), [
|
||||
field("Name", name), ...entryFields, h("label"), dynamic, ...timeoutFields, 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() });
|
||||
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: dynamic.input.checked ? [] : list.value(), dynamic: dynamic.input.checked,
|
||||
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() });
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
|
||||
Reference in new issue
Block a user