The new rule window shows chain, zones, addresses, services, family, action, log and comment; connection state, time window, rate limit, quota, connection limit and raw nftables open with "+" buttons (and close with ✕, which unsets them). Editing a rule opens what it uses. Group addresses appears only when a per-address limit, quota or connection limit is set. Clearer help for connection state and status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
20 KiB
JavaScript
297 lines
20 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, toast } from "../ui.js";
|
||
import { get, api, change, can } from "../api.js";
|
||
import { rerender } from "../app.js";
|
||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas } 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 [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
|
||
// As nftables checks them: the rules that count traffic first.
|
||
const rules = [...all.filter(countsTraffic), ...all.filter((r) => !countsTraffic(r))];
|
||
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) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
|
||
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
|
||
: [countsTraffic(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
||
{ 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) => {
|
||
if (r.pending === "deleted") return badge("deleted", "err");
|
||
// Moves stay among the rules of the same kind (counting traffic,
|
||
// or not): nftables checks those first.
|
||
const rs = live(rules).filter((x) => countsTraffic(x) === countsTraffic(r)), i = rs.indexOf(r);
|
||
return [
|
||
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ",
|
||
h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[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 }),
|
||
rules.some(countsTraffic) ? h("div", { class: "muted small" }, "Rules marked “every packet” have a quota or a byte rate: they are checked before established connections are accepted, so they count all of a connection's traffic, not only its first packet. They can drop, reject or log, not accept.") : null,
|
||
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
||
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
||
}
|
||
|
||
// countsTraffic: a quota or a byte rate (as model.Rule.CountsTraffic).
|
||
function countsTraffic(r) {
|
||
return !r.raw && (!!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit)));
|
||
}
|
||
|
||
function extras(r, q) {
|
||
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) {
|
||
const l = r.limit;
|
||
x.push((l.over ? "over " : "") + l.rate + (l.unit && l.unit !== "packets" ? " " + l.unit : "") + "/" + l.per +
|
||
(l.per_source ? " per source" : l.per_destination ? " per destination" : ""));
|
||
}
|
||
if (r.conn_limit) x.push("over " + r.conn_limit + " connections");
|
||
const out = x.length ? [h("div", { class: "muted small" }, x.join(" · "))] : [];
|
||
if (r.quota) out.push(quotaLine(r, q));
|
||
return out;
|
||
}
|
||
|
||
// quotaLine shows a quota, and what it used: in total, or per address
|
||
// (the busiest, and the rest in a dialog).
|
||
function quotaLine(r, q) {
|
||
const qt = r.quota;
|
||
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand";
|
||
let used = null;
|
||
if (q && !qt.per_source && !qt.per_destination) {
|
||
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
||
} else if (q) {
|
||
const n = (q.addresses || []).length;
|
||
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
||
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
|
||
}
|
||
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets,
|
||
used ? [" · ", used] : null,
|
||
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
||
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null);
|
||
}
|
||
|
||
async function usageDialog(r, q) {
|
||
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
||
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
||
table([
|
||
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
||
{ label: "", render: (a) => h("div", { class: "usage" }, h("div", { class: "usage-bar" + (a.used >= r.quota.bytes ? " full" : "") }, bar(a.used / r.quota.bytes))) },
|
||
], q.addresses)), [{ label: "Close", value: null }], { wide: true });
|
||
}
|
||
|
||
function bar(f) {
|
||
const seg = h("div", { class: "seg leased" });
|
||
seg.style.width = Math.min(100, f * 100) + "%";
|
||
return seg;
|
||
}
|
||
|
||
async function resetQuota(r) {
|
||
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
|
||
(r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||
try { await api("POST", "/rules/" + r.id + "/reset-quota"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); }
|
||
}
|
||
|
||
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 unit = select([["", "packets"], ["bytes", "bytes"], ["kbytes", "KB"], ["mbytes", "MB"]], l.unit || "", { class: "inline" });
|
||
const per = select(["second", "minute", "hour", "day"], l.per || "minute", { class: "inline" });
|
||
const burst = input({ value: l.burst || "", placeholder: "burst", class: "short", type: "number", min: 0 });
|
||
const over = checkbox("match beyond the rate (to drop floods, or traffic over a bandwidth)", l.over);
|
||
const keyOf = (x) => x.per_source ? "source" : x.per_destination ? "destination" : "";
|
||
const keys = [["", "for everything the rule matches"], ["source", "for each source address"], ["destination", "for each destination address"]];
|
||
const limitKey = select(keys, keyOf(l), { class: "inline" });
|
||
const qt = r.quota || {};
|
||
const gb = qt.bytes && qt.bytes % (1 << 30) === 0;
|
||
const quotaAmount = input({ value: qt.bytes ? qt.bytes / (gb ? 1 << 30 : 1 << 20) : "", placeholder: "50", class: "short", type: "number", min: 1 });
|
||
const quotaUnit = select([["mb", "MB"], ["gb", "GB"]], gb || !qt.bytes ? "gb" : "mb", { class: "inline" });
|
||
const quotaOver = checkbox("match once it is used up (to drop); otherwise while it lasts (to accept)", qt.over === undefined ? true : qt.over);
|
||
const quotaKey = select(keys, keyOf(qt), { class: "inline" });
|
||
const quotaReset = select([["", "only by hand"], ["daily", "daily, at midnight"], ["weekly", "weekly, on Mondays"], ["monthly", "monthly, on day"]], qt.reset || "", { class: "inline" });
|
||
const quotaDay = input({ value: qt.day || "", placeholder: "1", class: "short", type: "number", min: 1, max: 31 });
|
||
const showDay = () => quotaDay.classList.toggle("hidden", quotaReset.value !== "monthly");
|
||
quotaReset.addEventListener("change", showDay);
|
||
showDay();
|
||
const group4 = input({ value: r.group_v4 || "", placeholder: "32", class: "short", type: "number", min: 1, max: 32 });
|
||
const group6 = input({ value: r.group_v6 || "", placeholder: "64", class: "short", type: "number", min: 1, max: 128 });
|
||
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: "ip saddr @admins_v4 tcp dport 22 ct state new 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");
|
||
|
||
// The parts most rules don't need are behind "+" buttons: open when the
|
||
// rule uses them, closed (and unset) with ✕.
|
||
const adders = h("div", { class: "adders" });
|
||
const sections = {};
|
||
const section = (key, label, open, fields) => {
|
||
const nodes = fields.flat().filter(Boolean);
|
||
const sec = { open };
|
||
const add = h("button", { type: "button", class: "small", onclick: () => show(true) }, "+ " + label);
|
||
const close = h("button", { type: "button", class: "small close-section", title: "Remove " + label.toLowerCase(), onclick: () => show(false) }, "✕");
|
||
nodes[0].append(" ", close);
|
||
const show = (on) => {
|
||
sec.open = on;
|
||
for (const n of nodes) n.classList.toggle("hidden", !on);
|
||
add.classList.toggle("hidden", on);
|
||
if (on) { const f = nodes.find((n) => n.querySelector && n.querySelector("input, select, textarea")); if (f) f.querySelector("input, select, textarea").focus(); }
|
||
updateGroup();
|
||
};
|
||
adders.append(add, " ");
|
||
sections[key] = sec;
|
||
sec.nodes = nodes;
|
||
sec.init = () => show(open);
|
||
return nodes;
|
||
};
|
||
const stateFields = section("state", "Connection state", (r.ct_state || []).length > 0 || (r.ct_status || []).length > 0, [
|
||
field("Connection state", states, "new: a connection's first packet; established: its later ones; related: packets that belong to another connection (ICMP errors, FTP data); invalid: what connection tracking can't place. Empty: any."),
|
||
field("Connection status", statuses, "dnat: the connection was port forwarded; snat: its source was translated. Empty: any."),
|
||
]);
|
||
const timeFields = section("time", "Time window", (r.days || []).length > 0 || !!r.hour_from, [
|
||
field("Days", days),
|
||
field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight. In the firewall's local time."),
|
||
]);
|
||
const rateFields = section("rate", "Rate limit", !!r.limit, [
|
||
field("Rate limit", h("div", null, rate, " ", unit, " per ", per, " ", burst, " ", limitKey, over),
|
||
"In bytes, it is a bandwidth (KB or MB per second or minute); traffic beyond it is dropped, not queued. The burst is in the same unit."),
|
||
]);
|
||
const quotaFields = section("quota", "Quota", !!r.quota, [
|
||
field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver),
|
||
"An allowance of traffic; Housecarl keeps the count across changes and restarts."),
|
||
]);
|
||
const connFields = section("conn", "Connection limit", !!r.conn_limit, [
|
||
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."),
|
||
]);
|
||
// Grouping only means something with a per-address limit, quota or
|
||
// connection limit.
|
||
const groupFields = field("Group addresses", h("div", null, "IPv4 /", group4, " IPv6 /", group6),
|
||
"Per-address limits, connection limits and quotas count addresses by these prefixes. Default: each IPv4 address, IPv6 by /64 (one device has several addresses).").filter(Boolean);
|
||
function updateGroup() {
|
||
if (!sections.raw) return; // still building
|
||
const on = (sections.rate.open && limitKey.value !== "") || (sections.quota.open && quotaKey.value !== "") || sections.conn.open;
|
||
for (const n of groupFields) n.classList.toggle("hidden", !on);
|
||
}
|
||
limitKey.addEventListener("change", updateGroup);
|
||
quotaKey.addEventListener("change", updateGroup);
|
||
|
||
const body = h("div", null,
|
||
h("div", { class: "form-grid" },
|
||
field("Chain", ch),
|
||
...match.flat(),
|
||
field("Action", h("div", null, action, " ", rejectWith)),
|
||
...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields,
|
||
h("label"), adders,
|
||
field("Log", h("div", null, log, logPrefix)),
|
||
field("Comment", comment),
|
||
r.id ? [h("label"), disabled] : null),
|
||
err);
|
||
for (const sec of Object.values(sections)) sec.init();
|
||
updateGroup();
|
||
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 on = (k) => sections[k].open;
|
||
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: 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,
|
||
per_source: limitKey.value === "source", per_destination: limitKey.value === "destination" } : null,
|
||
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(),
|
||
raw: on("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;
|
||
}
|
||
}
|
||
}
|