From e841986a86c8db4f5d75ba91faba9202198e3a11 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Thu, 22 Oct 2026 23:51:54 +0100 Subject: [PATCH] Rule dialog: only the zone fields the chain can use Input and early have no outgoing interface, output no incoming one, so the dialog hides those fields (they could only be refused) and a zone left in a hidden field isn't sent. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ internal/web/static/assets/pages/rules.js | 4 ++++ internal/web/static/assets/widgets.js | 15 +++++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b004649..db708d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,10 @@ All notable changes to Housecarl are documented here. The format follows ruleset, for entries made before this version too: the configuration before and after it is got back from the current one and rendered by this version. +- The rule dialog shows only the zone fields its chain can use: no + "Going out on" for input and early, no "Coming in on" for output + (they could only be refused). A zone left in a field the chain hides + isn't sent. - History lists a changed setting by its own fields (`builtins.drop_invalid: false → true`), on the web page and in `hc history show`, instead of the whole `builtins` before and after. diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index 6062f00..6563f73 100644 --- a/internal/web/static/assets/pages/rules.js +++ b/internal/web/static/assets/pages/rules.js @@ -260,6 +260,9 @@ export async function ruleDialog(r, chain) { 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); @@ -371,6 +374,7 @@ export async function ruleDialog(r, chain) { 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"); diff --git a/internal/web/static/assets/widgets.js b/internal/web/static/assets/widgets.js index d30c087..d06c6ca 100644 --- a/internal/web/static/assets/widgets.js +++ b/internal/web/static/assets/widgets.js @@ -77,9 +77,11 @@ export function matchEditor(m, n, opts = {}) { const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", m.not_destinations); const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" }); const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || ""); + const inField = opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null; + const outField = opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null; const fields = [ - opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null, - opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null, + inField, + outField, field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."), field("To", h("div", null, dst, notDst)), field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."), @@ -88,10 +90,15 @@ export function matchEditor(m, n, opts = {}) { fields.value = () => { const v = { sources: src.value(), not_sources: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked, services: svc.value(), family: fam.value }; - if (opts.in !== false) Object.assign(v, { in_zones: inZ.value(), not_in: notIn.input.checked }); - if (opts.out !== false) Object.assign(v, { out_zones: outZ.value(), not_out: notOut.input.checked }); + // A hidden zone field (the chain has no such interface) matches nothing. + if (inField) Object.assign(v, inField[1].classList.contains("hidden") ? { in_zones: [], not_in: false } : { in_zones: inZ.value(), not_in: notIn.input.checked }); + if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: notOut.input.checked }); return v; }; + // zones shows the zone fields a chain can use: input and early have no + // outgoing interface, output no incoming one. + const show = (f, on) => f && f.filter(Boolean).forEach((e) => e.classList.toggle("hidden", !on)); + fields.zones = (inOn, outOn) => { show(inField, inOn); show(outField, outOn); }; return fields; }