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>
156 lines
10 KiB
JavaScript
156 lines
10 KiB
JavaScript
// 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;
|
||
}
|
||
}
|
||
}
|