Files
housecarl/internal/web/static/assets/pages/rules.js
T
abrown38andClaude Opus 5.5 9a165cf70b Staged undo, built-ins window, machine warnings; raw and dynamic set fixes
- Undo is staged like any edit: in review mode the web UI's Undo, and hc
  history undo with -C or HOUSECARL_CHANGESET, add a revert operation
  that checks, when replayed, that nothing it touches changed since;
  applied, History links the entries. Imports and takeovers still can't
  be staged.
- The rules page sums up a chain's built-ins in one line; a window lists
  them with what they do and their hits, and switches them.
- Warnings about the machine (dashboard, hc status): nftables.service
  enabled (it loads /etc/nftables.conf at boot and flushes every table
  when stopped), and the imported table still in force beside
  Housecarl's. The docs say to disable nftables.service but not stop it.
- A raw rule with other fields set is refused (raw_conflict) instead of
  silently ignoring them; the rule dialog's nftables section hides what
  it replaces, and the importer's raw rules carry only their text.
- A dynamic set with configured entries is refused
  (set_dynamic_entries): they were silently ignored. The set dialog
  shows entries or the entry timeout, as fits.
- The rule dialog's chain choices are just the chains' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00

370 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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: "#", 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)));
}
// 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 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 (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)).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, 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;
}
}
}