// 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"); 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, " ", unit, " per ", per, " ", burst, " ", limitKey, over), "Empty: no limit. 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."), field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver), "Empty: none. An allowance of traffic; Housecarl keeps the count across changes and restarts."), 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)."), 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), 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: 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: 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; } } }