// 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, usageBar, lookupNames, named, pct } 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) { // ?rule=ID (from the matrix, the log, the tracer, statistics): show // that rule, in its chain, highlighted. const target = Number(query.get("rule")) || 0; let chain = query.get("chain"); if (!chain && target) { try { chain = (await get("/rules/" + target)).chain; } catch { /* gone: the default chain */ } } chain = 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(everyPacket), ...all.filter((r) => !everyPacket(r))]; const write = can("config", true); const info = chains.find((c) => c[0] === chain) || chains[0]; const policy = settings[chain + "_policy"]; 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" }, builtinsRow(chain, settings, cs), table([ { label: "Order", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) }, { label: "ID", render: (r) => r.id ? h("span", { class: "muted mono small" }, "#" + r.id) : "" }, { label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)] : [everyPacket(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) => everyPacket(x) === everyPacket(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", title: "Move to another place, in one change", disabled: rs.length < 2, onclick: () => moveDialog(r, rs, (x) => live(rules).indexOf(x) + 1) }, "Move…"), " ", 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) => rowClass(r) + (target && r.id === target ? " row-target" : "") }), rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. 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))); if (target) { const row = el.querySelector("tr.row-target"); if (row) requestAnimationFrame(() => row.scrollIntoView({ block: "center" })); else el.querySelector(".tabs").after(h("div", { class: "msg warn" }, "There is no rule #" + target + " (any more).")); } } // everyPacket: checked before established connections are accepted (as // model.Rule.ChecksEveryPacket): asked to, or a quota or a byte rate. function everyPacket(r) { return !r.raw && (!!r.every_packet || !!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(",")); 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 sums up a quota on the rule's row: what it used (in total, // or how many addresses and the busiest); the rest is in its 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" : qt.reset || "reset by hand"; let used = fmtBytes(qt.bytes) + each; if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")"; else if (q) { const a = q.addresses || [], n = a.length; const up = a.filter((x) => x.used >= qt.bytes).length; used += " · " + (n === 1 ? "1 address" : n + " addresses") + (up ? ", " + up + " used up" : "") + (n ? ", busiest " + fmtBytes(a[0].used) + " (" + pct(a[0].used, qt.bytes) + ")" : ""); } const text = "quota " + used + " · " + resets; return h("div", { class: "muted small" }, q && r.pending !== "new" ? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text); } // quotaDialog shows a quota in full: what it used (each address's, for a // per-address one), its resets, and resetting or giving back. async function quotaDialog(r, q) { const qt = r.quota; const perAddress = qt.per_source || qt.per_destination; const write = can("config", true); const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand"; const when = (t) => t ? new Date(t).toLocaleString() : "–"; const names = perAddress ? await lookupNames((q.addresses || []).map((a) => a.address)) : {}; // A row's action closes this dialog first: it opens one of its own. const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button:last-child").click(); f(); }; // Close const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null, h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " + (qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."), h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)), perAddress ? table([ { label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) }, { label: "Used", render: (a) => fmtBytes(a.used) }, { label: "", class: "num", tdClass: "num", render: (a) => pct(a.used, qt.bytes) }, { label: "", render: (a) => usageBar(a.used, qt.bytes) }, write ? { label: "", render: (a) => h("span", { class: "nowrap" }, h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ", h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null, ].filter(Boolean), q.addresses || [], { empty: "No address has used it since the last reset." }) : h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")", usageBar(q.used, qt.bytes))), [ ...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []), { label: "Close", value: null }, ], { wide: perAddress }); if (v === "reset") resetQuota(r); if (v === "give") giveBack(r); } // giveBack takes an amount off what a quota used (one address's, or // everyone's): extra allowance until its next reset. async function giveBack(r, address) { const amount = input({ type: "number", min: 1, value: "1", class: "short" }); const unit = select([["mb", "MB"], ["gb", "GB"]], "gb", { class: "inline" }); const perAddress = r.quota.per_source || r.quota.per_destination; const who = address ? " for " + address : perAddress ? " for every address" : ""; await modal("Give back quota", h("div", null, h("p", null, "Take this much off what " + (r.comment || "rule #" + r.id) + "'s quota used" + who + ": extra allowance until its next reset, without changing the rule. This is done at once, not staged."), field("Amount", h("div", null, amount, " ", unit))), [ { label: "Cancel", value: null }, { label: "Give back", kind: "primary", onclick: async () => { const n = parseInt(amount.value, 10); if (!(n > 0)) { toast("Give an amount of 1 or more", "err"); return false; } const give = n * (unit.value === "gb" ? 1 << 30 : 1 << 20); try { await api("POST", "/rules/" + r.id + "/reset-quota", { body: address ? { give, address } : { give } }); toast("Gave back " + fmtBytes(give)); rerender(); } catch (e) { modal("Not given back", errorBox(e)); return false; } } }, ]); } async function resetQuota(r, address) { if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" + (address ? " for " + address : 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", address ? { body: { address } } : {}); 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)); } } // moveDialog puts a rule anywhere among rs (the rules it can move // among) in one move: one change, or one staged operation. async function moveDialog(r, rs, order) { const others = rs.filter((x) => x !== r); const label = (x) => order(x) + ". " + (x.comment || (x.raw ? x.raw : describe(x))) + " (#" + x.id + ")"; const next = rs[rs.indexOf(r) + 1]; const where = select([...others.map((x) => [String(x.id), "before " + label(x)]), ["end", "after " + label(others[others.length - 1])]], next ? String(next.id) : "end"); const err = h("div"); await modal("Move " + (r.comment || "rule #" + r.id), h("div", null, h("div", { class: "form-grid" }, field("Put it", where)), err), [ { label: "Cancel", value: null }, { label: "Move", kind: "primary", onclick: async () => { const v = where.value; if (v === (next ? String(next.id) : "end")) return true; // where it is try { await change("POST", "/rules/" + r.id + "/move", v === "end" ? { after: others[others.length - 1].id } : { before: Number(v) }, { quiet: true }); rerender(); } catch (e) { mount(err, errorBox(e)); return false; } } }, ]); } 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", "forward", "output", "early"], r.chain || chain); const match = matchEditor(r, n); const chainZones = () => match.zones(ch.value !== "output", ch.value === "forward" || ch.value === "output"); ch.addEventListener("change", chainZones); chainZones(); 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 every = checkbox("every packet: also stop connections already open, not just new ones", r.every_packet); const showReject = () => { rejectWith.classList.toggle("hidden", action.value !== "reject"); every.classList.toggle("hidden", action.value === "accept"); }; 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 (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(); }; 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, "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. 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); // 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, every), "Every packet: checked before established connections are accepted, so blocking takes effect on what is already connected.").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); if (!on) chainZones(); 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), ...matchNodes, ...actionField, ...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields, addersLabel, adders, ...logField, 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 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, 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: 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, every_packet: !isRaw && action.value !== "accept" && every.input.checked, 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 { 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; } } }