From 24dbc0a869045458fee126364848a8549978b42f Mon Sep 17 00:00:00 2001 From: abrown38 Date: Wed, 21 Oct 2026 12:12:57 +0100 Subject: [PATCH] Rules read as a direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A match's zones are written "lan → wan" (or "any but wan → any", and "lan → firewall" / "firewall → wan" in the input and output chains) instead of "in lan · out wan" and "in not wan"; exclusions read "from any but X". The Rules and NAT pages and hc; flags and JSON unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++ cmd/hc/main_test.go | 20 +++++++++-- cmd/hc/rules.go | 50 ++++++++++++++++++--------- internal/web/static/assets/widgets.js | 15 +++++--- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aecf884..e136dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,15 @@ All notable changes to Housecarl are documented here. The format follows - Import: sets with `flags timeout` become dynamic sets; a DNAT of several ports becomes one port forward per port. +### Changed + +- **Rules read as a direction.** A match's zones are written `lan → wan`, + `any but wan → any`, or with the firewall itself as the other end + (`lan → firewall` in input, `firewall → wan` in output), instead of + `in lan · out wan` and `in not wan`; exclusions read `from any but X`. + On the Rules and NAT pages and in `hc`; the flags and JSON stay as + they were. + ### Fixed - `hc stats top devices` listed each device twice (once for what it diff --git a/cmd/hc/main_test.go b/cmd/hc/main_test.go index f72d642..76cc932 100644 --- a/cmd/hc/main_test.go +++ b/cmd/hc/main_test.go @@ -35,9 +35,23 @@ func TestLimitAndDescribe(t *testing.T) { if _, err := parseLimit("fast"); err == nil { t.Error("a bad limit accepted") } - m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}} - if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" { - t.Errorf("describe: %q", got) + for _, c := range []struct { + m model.Match + chain string + want string + }{ + {model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}, "input", + "wan → firewall from any but trusted ssh,tcp/8080"}, + {model.Match{InZones: []string{"wan"}, NotIn: true, Destinations: []string{"trusted_ips"}}, "forward", "any but wan → any to trusted_ips"}, + {model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, "forward", "lan → wan"}, + {model.Match{OutZones: []string{"wan"}}, "output", "firewall → wan"}, + {model.Match{OutZones: []string{"wan"}, Sources: []string{"10.0.0.0/8"}}, "", "any → wan from 10.0.0.0/8"}, + {model.Match{Sources: []string{"trusted"}}, "input", "from trusted"}, + {model.Match{}, "forward", "everything"}, + } { + if got := describe(c.m, c.chain); got != c.want { + t.Errorf("describe(%+v, %s) = %q, want %q", c.m, c.chain, got, c.want) + } } if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" { t.Errorf("editList: %v", got) diff --git a/cmd/hc/rules.go b/cmd/hc/rules.go index 0dc0419..7779c1c 100644 --- a/cmd/hc/rules.go +++ b/cmd/hc/rules.go @@ -64,22 +64,40 @@ func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) { set("family", "family", m.family) } -// describe sums up a match: "in lan from trusted ssh, dns". -func describe(m model.Match) string { +// describe sums up a match in a chain ("" for NAT): "lan → firewall +// from trusted ssh,dns". Zones are a direction; the firewall itself is +// the other end of the input and output chains. +func describe(m model.Match, chain string) string { var parts []string - side := func(word string, not bool, list []string) { - if len(list) == 0 { - return - } + list := func(not bool, xs []string) string { + s := strings.Join(xs, ",") if not { - word += " not" + return "any but " + s } - parts = append(parts, word+" "+strings.Join(list, ",")) + return s + } + if len(m.InZones) > 0 || len(m.OutZones) > 0 { + in, out := "any", "any" + if len(m.InZones) > 0 { + in = list(m.NotIn, m.InZones) + } + if len(m.OutZones) > 0 { + out = list(m.NotOut, m.OutZones) + } + switch chain { + case model.ChainInput: + out = "firewall" + case model.ChainOutput: + in = "firewall" + } + parts = append(parts, in+" → "+out) + } + if len(m.Sources) > 0 { + parts = append(parts, "from "+list(m.NotSources, m.Sources)) + } + if len(m.Destinations) > 0 { + parts = append(parts, "to "+list(m.NotDests, m.Destinations)) } - side("in", m.NotIn, m.InZones) - side("out", m.NotOut, m.OutZones) - side("from", m.NotSources, m.Sources) - side("to", m.NotDests, m.Destinations) if len(m.Services) > 0 { parts = append(parts, strings.Join(m.Services, ",")) } @@ -281,7 +299,7 @@ func ruleCmd() *cobra.Command { return output(rs, func(w io.Writer) { fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT") for _, r := range rs { - what := describe(r.Match) + what := describe(r.Match, r.Chain) if r.Raw != "" { what = "raw: " + r.Raw } @@ -304,7 +322,7 @@ func ruleCmd() *cobra.Command { cmd.AddCommand(ls) cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) { if r.Raw == "" { - fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action) + fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match, r.Chain), ruleExtras(r), r.Action) } else { fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain) } @@ -700,7 +718,7 @@ func snatCmd() *cobra.Command { if to == "" { to = "masquerade" } - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment) + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment) } }) }, @@ -780,7 +798,7 @@ func redirectCmd() *cobra.Command { return output(xs, func(w io.Writer) { fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT") for _, x := range xs { - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment) + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment) } }) }, diff --git a/internal/web/static/assets/widgets.js b/internal/web/static/assets/widgets.js index c1f1f03..8fd06e1 100644 --- a/internal/web/static/assets/widgets.js +++ b/internal/web/static/assets/widgets.js @@ -98,11 +98,16 @@ export function matchEditor(m, n, opts = {}) { // describe sums up a match: "in lan · from trusted · ssh, dns". export function describe(m) { const parts = []; - const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); }; - side("in", m.not_in, m.in_zones); - side("out", m.not_out, m.out_zones); - side("from", m.not_sources, m.sources); - side("to", m.not_destinations, m.destinations); + const list = (not, xs) => (not ? "any but " : "") + xs.join(", "); + // Zones are a direction, "lan → wan"; the firewall itself is the other + // end of the input and output chains. + const ins = m.in_zones || [], outs = m.out_zones || []; + if (ins.length || outs.length) { + parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " + + (m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any")); + } + if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources)); + if (m.destinations && m.destinations.length) parts.push("to " + list(m.not_destinations, m.destinations)); if (m.services && m.services.length) parts.push(m.services.join(", ")); if (m.family) parts.push(m.family + " only"); return parts.length ? parts.join(" · ") : "everything";