A rule named in the matrix, the log, the tracer, statistics or the dashboard opens the Rules page on its chain, scrolled to it and highlighted (#/rules?rule=ID; the chain is looked up when the link has none). The Rules page shows each rule's ID beside its order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
25 KiB
JavaScript
390 lines
25 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) {
|
||
// ?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", 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 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", "forward", "output", "early"], 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 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);
|
||
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;
|
||
}
|
||
}
|
||
}
|