// 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; } } }