Zone matrix: partly, what is let through, hits; hc matrix aligned

A cell reads allow, block or partly (some new connections let through,
the rest not), with its exceptions counted by what they do (+3 -1), and
what they let through: services with the addresses they are limited to,
under the grid in hc matrix and in each cell on the Matrix page. A zone
to itself is marked: only traffic routed between its interfaces passes
the firewall. The details list each rule's hits. hc matrix pads the
grid by visible width: the colours' escape codes had thrown it out of
line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 19:00:33 +01:00
1 parent 429c910df1
commit 5ee324c588
14 files changed
+423 -65

No files matched your search

+28
View File
@@ -467,6 +467,26 @@ type MatrixCell struct {
DecidedBy string `json:"decided_by"`
RuleID int64 `json:"rule_id,omitempty"` // the deciding rule
Exceptions []MatrixException `json:"exceptions"`
// Summary is the pair at a glance: allowed or blocked when every new
// connection gets the same, partly when some are let through and
// some not.
Summary string `json:"summary"`
// Allowed and Blocked count the exceptions that let part of the
// traffic through, and that block part of it.
Allowed int `json:"allowed"`
Blocked int `json:"blocked"`
// Open sums up what the allowing exceptions let through, shortest
// first in their order: "ssh", "dns", "5 port forwards".
Open []string `json:"open"`
// SameZone: from a zone to itself. Note says what of that traffic
// passes the firewall at all.
SameZone bool `json:"same_zone,omitempty"`
Note string `json:"note,omitempty"`
// Hits are the deciding rule's (or policy's) packets, all of its
// traffic since housecarld started (nil: no counter).
Hits *uint64 `json:"hits,omitempty"`
// Marks are the counters behind Hits.
Marks []string `json:"-"`
}
// MatrixException is a rule (or built-in) that decides for part of a
@@ -480,4 +500,12 @@ type MatrixException struct {
Verdict string `json:"verdict"`
// What it covers: "from trusted · ssh", "Monday 08:00–17:00".
What string `json:"what"`
// Short is What in a word or two, for the overview: its services,
// or else its first condition.
Short string `json:"short"`
// Hits are its packets, all of its traffic since housecarld started
// (a rule may apply to several pairs; nil: no counter).
Hits *uint64 `json:"hits,omitempty"`
// Marks are the counters behind Hits ("r12", "b-ping", "f3").
Marks []string `json:"-"`
}
+102 -14
View File
@@ -63,6 +63,50 @@ func Cell(st *model.State, o Options, from, to string) (api.MatrixCell, bool) {
}
func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
c := walkCell(st, o, chain, from, to)
sum(&c)
if from == to && chain == model.ChainForward {
c.SameZone, c.Note = true, sameZoneNote(st.Zone(from))
}
return c
}
// sum fills in the overview: the summary, the exceptions counted by
// verdict, and what the allowing ones let through.
func sum(c *api.MatrixCell) {
c.Open = []string{}
seen := map[string]bool{}
for _, e := range c.Exceptions {
switch e.Verdict {
case "allowed":
c.Allowed++
if !seen[e.Short] {
seen[e.Short] = true
c.Open = append(c.Open, e.Short)
}
case "blocked":
c.Blocked++
}
}
c.Summary = c.Verdict
if c.Verdict == "blocked" && c.Allowed > 0 || c.Verdict == "allowed" && c.Blocked > 0 {
c.Summary = "partly"
}
}
// sameZoneNote says what of a zone's traffic to itself the firewall sees:
// only what it routes between the zone's interfaces.
func sameZoneNote(z *model.Zone) string {
if z == nil {
return ""
}
if len(z.Interfaces) == 1 && !strings.HasSuffix(z.Interfaces[0], "*") {
return fmt.Sprintf("traffic within %s stays on its own network: it passes the firewall only if routed back out of %s", z.Name, z.Interfaces[0])
}
return fmt.Sprintf("only traffic routed between %s's interfaces (%s) passes the firewall; within one of them it stays on its own network", z.Name, strings.Join(z.Interfaces, ", "))
}
func walkCell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
c := api.MatrixCell{From: from, To: to, Chain: chain, Exceptions: []api.MatrixException{}}
b := st.Settings.Builtins
fromZone := st.Zone(from)
@@ -71,7 +115,7 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
if chain != model.ChainOutput {
if b.Antispoof && external {
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: model.ChainEarly, Label: "built-in: antispoof", Verdict: "blocked",
What: "sources that can't come from outside (private ranges, your own prefixes)"})
What: "sources that can't come from outside (private ranges, your own prefixes)", Short: "spoofed sources", Marks: []string{"b-antispoof", "b-antispoof6"}})
}
if decided := walk(st, o, &c, model.ChainEarly, from, ""); decided {
return c
@@ -81,22 +125,26 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
switch chain {
case model.ChainInput:
if b.ICMPv6 {
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD"})
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD",
Short: "ICMPv6", Marks: []string{"b-icmpv6", "b-icmpv6-nd", "b-icmpv6-mld"}})
}
if b.PingInternal && !external {
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: ping", Verdict: "allowed", What: "ping (ICMP echo)"})
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: ping", Verdict: "allowed", What: "ping (ICMP echo)", Short: "ping", Marks: []string{"b-ping"}})
}
if b.DHCPv6Client && external {
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: dhcpv6-client", Verdict: "allowed", What: "DHCPv6 replies to the firewall"})
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: dhcpv6-client", Verdict: "allowed", What: "DHCPv6 replies to the firewall",
Short: "DHCPv6", Marks: []string{"b-dhcpv6-client"}})
}
case model.ChainForward:
if n := forwards(st); n > 0 && external && !st.Zone(to).External {
if marks := forwards(st); len(marks) > 0 && external && !st.Zone(to).External {
n := fmt.Sprintf("%d port forward%s", len(marks), map[bool]string{true: "", false: "s"}[len(marks) == 1])
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "port forwards", Verdict: "allowed",
What: fmt.Sprintf("%d port forward%s to inside hosts", n, map[bool]string{true: "", false: "s"}[n == 1])})
What: n + " to inside hosts", Short: n, Marks: marks})
}
case model.ChainOutput:
if st.Settings.OutputPolicy == model.PolicyDrop && b.ICMPv6 {
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD"})
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD",
Short: "ICMPv6", Marks: []string{"b-icmpv6"}})
}
}
if walk(st, o, &c, chain, from, to) {
@@ -104,17 +152,22 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
}
policy := map[string]string{model.ChainInput: st.Settings.InputPolicy, model.ChainForward: st.Settings.ForwardPolicy, model.ChainOutput: st.Settings.OutputPolicy}[chain]
c.Verdict, c.DecidedBy = verdictOf(policy), chain+" policy"
if policy == model.PolicyDrop {
c.Marks = []string{"p-" + chain} // only a drop policy is counted
}
return c
}
func forwards(st *model.State) int {
n := 0
// forwards returns the counters' marks of the port forwards that send
// connections inside.
func forwards(st *model.State) []string {
var marks []string
for _, f := range st.Forwards {
if !f.Disabled && f.To != "" {
n++
marks = append(marks, fmt.Sprintf("f%d", f.ID))
}
}
return n
return marks
}
// walk goes through a chain's rules for the pair, as nftables checks
@@ -130,8 +183,10 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string)
}
}
for _, r := range rules {
mark := []string{fmt.Sprintf("r%d", r.ID)}
if r.Raw != "" {
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: "unknown", What: "a raw rule: " + r.Raw})
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: "unknown", What: "a raw rule: " + r.Raw,
Short: "a raw rule", Marks: mark})
continue
}
if !zones(r, from, to) || !forNew(r) {
@@ -151,10 +206,11 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string)
if what == "" {
what = "everything"
}
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: v, What: what})
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: v, What: what,
Short: short(r, what), Marks: mark})
continue
}
c.Verdict, c.DecidedBy, c.RuleID = v, "rule "+r.Label(), r.ID
c.Verdict, c.DecidedBy, c.RuleID, c.Marks = v, "rule "+r.Label(), r.ID, mark
return true
}
return false
@@ -265,6 +321,38 @@ func forNew(r *model.Rule) bool {
return false
}
// short sums up an exception for the overview: its services (ICMP types
// as just icmp or icmpv6), with the addresses it is limited to, which
// matter as much ("tcp/22 from trusted"); times and rates are left to
// the details.
func short(r *model.Rule, what string) string {
var parts []string
if len(r.Services) > 0 {
var svcs []string
seen := map[string]bool{}
for _, s := range r.Services {
if p, _, ok := strings.Cut(s, "/"); ok && (p == "icmp" || p == "icmpv6") {
s = p
}
if !seen[s] {
seen[s] = true
svcs = append(svcs, s)
}
}
parts = append(parts, strings.Join(svcs, ", "))
}
for _, c := range strings.Split(what, " · ") {
if strings.HasPrefix(c, "from ") || strings.HasPrefix(c, "to ") {
parts = append(parts, c)
}
}
if len(parts) == 0 {
first, _, _ := strings.Cut(what, " · ")
return first
}
return strings.Join(parts, " ")
}
// conditions says what limits a rule to part of a pair's traffic ("" for
// none: it decides the pair).
func conditions(st *model.State, r *model.Rule) string {
+48
View File
@@ -110,3 +110,51 @@ func TestConnected(t *testing.T) {
t.Errorf("guest → lan: %v", ids(guestLan)) // not "to a guest": it is in guest, not lan
}
}
// TestSummary: each pair at a glance, what its exceptions let through,
// and the counters behind each line.
func TestSummary(t *testing.T) {
st := state()
st.Settings.InputPolicy = model.PolicyDrop
m := Build(st)
for _, c := range []struct {
from, to, summary string
allowed, blocked int
open string
}{
{"lan", "wan", "allowed", 0, 0, ""},
{"guest", "wan", "partly", 1, 0, "http, https"},
{"wan", "lan", "partly", 1, 1, "1 port forward"},
{"lan", "guest", "blocked", 0, 0, ""},
{"lan", Firewall, "partly", 4, 0, "ICMPv6|ping|ssh from admins|dns"},
} {
got := find(m, c.from, c.to)
if got.Summary != c.summary || got.Allowed != c.allowed || got.Blocked != c.blocked || strings.Join(got.Open, "|") != c.open {
t.Errorf("%s → %s: %s +%d -%d %q; want %s +%d -%d %q", c.from, c.to, got.Summary, got.Allowed, got.Blocked, got.Open, c.summary, c.allowed, c.blocked, c.open)
}
}
if c := find(m, "lan", "lan"); !c.SameZone || !strings.Contains(c.Note, "out of eth1") {
t.Errorf("same zone: %+v", c)
}
if c := find(m, "lan", "wan"); c.SameZone || c.Note != "" {
t.Errorf("not the same zone: %+v", c)
}
wl := find(m, "wan", "lan")
if strings.Join(wl.Exceptions[0].Marks, ",") != "b-antispoof,b-antispoof6" || strings.Join(wl.Exceptions[1].Marks, ",") != "f30" || strings.Join(wl.Marks, ",") != "r15" {
t.Errorf("marks: %+v", wl)
}
icmp := &model.Rule{Match: model.Match{Services: []string{"icmp/echo-request", "icmp/echo-reply", "icmpv6/echo-request", "ssh"}}}
if s := short(icmp, "x"); s != "icmp, icmpv6, ssh" {
t.Errorf("short: %q", s)
}
ssh := &model.Rule{Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Days: []string{"mon"}}
if s := short(ssh, conditions(st, ssh)); s != "ssh from trusted" {
t.Errorf("short with a source: %q", s)
}
if s := short(&model.Rule{Days: []string{"mon"}}, "mon"); s != "mon" {
t.Errorf("short of a time: %q", s)
}
if c := find(m, "lan", Firewall); strings.Join(c.Marks, ",") != "p-input" {
t.Errorf("a drop policy's mark: %v", c.Marks)
}
}
+31 -1
View File
@@ -15,6 +15,7 @@ import (
"housecarl/internal/engine"
"housecarl/internal/matrix"
"housecarl/internal/model"
"housecarl/internal/nft"
"housecarl/internal/store"
)
@@ -471,10 +472,39 @@ func (s *Server) zoneMatrix(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err != nil {
return err
}
writeJSON(w, http.StatusOK, matrix.Build(st, matrix.Options{Connected: s.connected(st)}))
m := matrix.Build(st, matrix.Options{Connected: s.connected(st)})
if t, err := s.fw.Table(r.Context()); err == nil {
matrixHits(&m, t.Counters)
}
writeJSON(w, http.StatusOK, m)
return nil
}
// matrixHits fills in the hits of the matrix's rules, built-ins and
// policies from the counters.
func matrixHits(m *api.Matrix, counters map[string]nft.Counter) {
hits := func(marks []string) *uint64 {
var n uint64
found := false
for _, mk := range marks {
if c, ok := counters[mk]; ok {
n, found = n+c.Packets, true
}
}
if !found {
return nil
}
return &n
}
for i := range m.Cells {
c := &m.Cells[i]
c.Hits = hits(c.Marks)
for j := range c.Exceptions {
c.Exceptions[j].Hits = hits(c.Exceptions[j].Marks)
}
}
}
// connected maps the networks on the firewall's interfaces (link-local
// and loopback aside) to their zones.
func (s *Server) connected(st *model.State) map[netip.Prefix]string {
+7
View File
@@ -891,6 +891,13 @@ func TestMatrixEndpoint(t *testing.T) {
if c := cell("lan", "wan"); c.Verdict != "allowed" {
t.Errorf("lan → wan with the changeset: %+v", c)
}
// Hits: summed over an object's counters; none without a counter.
hm := api.Matrix{Cells: []api.MatrixCell{{Marks: []string{"p-forward"}, Exceptions: []api.MatrixException{
{Marks: []string{"b-antispoof", "b-antispoof6"}}, {Marks: []string{"r9"}}}}}}
matrixHits(&hm, map[string]nft.Counter{"p-forward": {Packets: 4}, "b-antispoof": {Packets: 2}, "b-antispoof6": {Packets: 3}})
if c := hm.Cells[0]; c.Hits == nil || *c.Hits != 4 || c.Exceptions[0].Hits == nil || *c.Exceptions[0].Hits != 5 || c.Exceptions[1].Hits != nil {
t.Errorf("hits: %+v", c)
}
}
// TestHostProblems: an enabled nftables.service, and an imported table
+22 -10
View File
@@ -11,7 +11,7 @@ export async function render(el) {
const name = (z) => z === "firewall" ? h("em", null, "firewall") : z;
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Zone matrix"),
h("div", { class: "muted" }, "What happens to a new connection from a zone (rows) to a zone (columns), or to and from the firewall itself, worked out from the rules in order: allowed or blocked, and how many rules decide for part of the traffic first (some services, addresses, times). Click a cell for the rules."))),
h("div", { class: "muted" }, "What happens to a new connection from a zone (rows) to a zone (columns), or to and from the firewall itself, worked out from the rules in order: all allowed, all blocked, or partly (some services, addresses or times let through, the rest not). Click a cell for the rules."))),
pending ? h("div", { class: "msg info" }, "Shown with your pending changes.") : null,
!m.zones.length ? h("div", { class: "panel" }, h("div", { class: "empty" }, "No zones yet: add them under Objects → Zones.")) :
h("div", { class: "panel" }, h("div", { class: "table-wrap" }, h("table", { class: "matrix" },
@@ -19,20 +19,32 @@ export async function render(el) {
h("tbody", null, cols.map((from) => h("tr", null, h("th", null, name(from)), cols.map((to) => {
const c = cells.get(from + "\u0000" + to);
if (!c) return h("td", { class: "matrix-none" }, "–");
const n = c.exceptions.length;
return h("td", { class: "matrix-" + c.verdict + (n ? " matrix-some" : ""), title: c.verdict + " by " + c.decided_by + (n ? ", " + n + " exception" + (n === 1 ? "" : "s") + " first" : "") },
h("button", { type: "button", class: "matrix-cell", onclick: () => detail(c) }, c.verdict, n ? h("span", { class: "matrix-n" }, "+" + n) : null));
const open = c.open || [];
const counts = [c.allowed ? "+" + c.allowed : "", c.blocked ? "−" + c.blocked : ""].filter(Boolean).join(" ");
const title = (c.same_zone ? c.note + ". " : "") + "The rest: " + c.verdict + " by " + c.decided_by +
(c.allowed ? "; " + c.allowed + " rule(s) let part of it through" : "") + (c.blocked ? "; " + c.blocked + " block part of it" : "");
return h("td", { class: "matrix-" + c.summary + (c.same_zone ? " matrix-same" : ""), title },
h("button", { type: "button", class: "matrix-cell", onclick: () => detail(c) },
c.summary === "partly" ? "partly" : c.verdict, counts ? h("span", { class: "matrix-n" }, counts) : null,
open.length && !c.same_zone ? h("span", { class: "matrix-open" }, open.slice(0, 3).join(" · ") + (open.length > 3 ? " · +" + (open.length - 3) + " more" : "")) : null));
})))))),
h("p", { class: "muted small" }, "Zone to zone is the forward chain; to the firewall, input; from it, output. Exceptions are rules (and built-ins such as ping or port forwards) that decide for some of the traffic before the rest falls to the deciding rule or the policy. Replies and established connections aren't shown: the built-in lets those through.")));
h("p", { class: "muted small" }, "Zone to zone is the forward chain; to the firewall, input; from it, output. +N: rules (and built-ins such as ping or port forwards) that let part of the traffic through before the rest falls to the deciding rule or the policy; −N: those that block part of it. Under each cell, what is let through. A zone to itself (faded): only traffic routed between its interfaces passes the firewall. Replies and established connections aren't shown: the built-in lets those through.")));
}
function detail(c) {
const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label;
const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]);
const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString());
const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary];
modal(c.from + " → " + c.to, h("div", null,
h("p", { class: "muted" }, "New connections, checked in this order (the " + c.chain + " chain):"),
h("div", { class: "table-wrap" }, h("table", null, h("tbody", null,
c.exceptions.map((e) => h("tr", null, h("td", null, v(e.verdict)), h("td", null, e.what), h("td", { class: "small" }, ruleLink(e.rule_id, e.chain, e.label)))),
h("tr", { class: "matrix-rest" }, h("td", null, v(c.verdict)), h("td", null, h("strong", null, c.exceptions.length ? "the rest" : "everything")),
h("td", { class: "small" }, ruleLink(c.rule_id, c.chain, c.decided_by))))))), [{ label: "Close", value: null }], { wide: true });
h("p", null, h("strong", null, summary), h("span", { class: "muted" }, " · new connections, checked in this order (the " + c.chain + " chain):")),
c.note ? h("div", { class: "msg info" }, c.note[0].toUpperCase() + c.note.slice(1) + ".") : null,
h("div", { class: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, ""), h("th", null, "What"), h("th", null, "By"), h("th", { class: "num" }, "Hits"))),
h("tbody", null,
c.exceptions.map((e) => h("tr", null, h("td", null, v(e.verdict)), h("td", null, e.what), h("td", { class: "small" }, ruleLink(e.rule_id, e.chain, e.label)), hits(e.hits))),
h("tr", { class: "matrix-rest" }, h("td", null, v(c.verdict)), h("td", null, h("strong", null, c.exceptions.length ? "the rest" : "everything")),
h("td", { class: "small" }, ruleLink(c.rule_id, c.chain, c.decided_by)), hits(c.hits))))),
h("p", { class: "muted small" }, "Hits: each rule's packets since housecarld started, in all its traffic: a rule can cover several pairs.")),
[{ label: "Close", value: null }], { wide: true });
}
+7 -2
View File
@@ -425,10 +425,15 @@ table.matrix th, table.matrix td { text-align: center; }
table.matrix tbody th { text-align: left; }
table.matrix th.corner { font-weight: 400; color: var(--muted); text-transform: none; }
table.matrix td { padding: 4px; }
.matrix-cell { width: 100%; min-width: 7em; border: 0; background: transparent; color: inherit; font-weight: 600; padding: 6px 8px; cursor: pointer; }
table.matrix { table-layout: fixed; width: 100%; min-width: 640px; }
table.matrix thead th:first-child, table.matrix tbody th { width: 8em; }
.matrix-cell { display: block; width: 100%; min-width: 7em; border: 0; background: transparent; color: inherit; font-weight: 600; padding: 6px 8px; cursor: pointer; white-space: normal; text-align: center; }
td.matrix-allowed { background: var(--add); color: var(--add-text); }
td.matrix-blocked { background: var(--del); color: var(--del-text); }
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; color: var(--warn); }
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; }
td.matrix-partly { background: var(--warn-soft); color: var(--text); }
td.matrix-same { opacity: 0.55; }
.matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; }
td.matrix-none { color: var(--muted); }
tr.matrix-rest td { border-top: 2px solid var(--border); }