Files
housecarl/internal/matrix/matrix_test.go
T
abrown38andClaude Opus 5.5 b6f31c86a6 Zone matrix: what the rules do between zones
internal/matrix walks each pair's rules in order, as nftables would, for
new connections: zone → zone (forward), zone → the firewall (input) and
back (output), with the built-ins that act on new connections. A rule
for the whole pair decides it; one for part of its traffic (addresses,
services, times, rates, a quota) is an exception; the rest falls to the
policy. Rules whose addresses all lie in networks connected to other
zones are left out of a pair. GET /matrix (with ?changeset, pending
changes), hc matrix [FROM TO], and a Matrix page whose cells open their
rules.

e2e: 166 checks, among them the matrix of the maintainer's imported
ruleset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:35:26 +01:00

113 lines
5.2 KiB
Go

package matrix
import (
"fmt"
"net/netip"
"strings"
"testing"
"housecarl/internal/api"
"housecarl/internal/model"
)
func state() *model.State {
st := model.NewState()
st.Zones = []*model.Zone{
{ID: 1, Name: "guest", Interfaces: []string{"eth2"}},
{ID: 2, Name: "lan", Interfaces: []string{"eth1"}},
{ID: 3, Name: "wan", Interfaces: []string{"wan"}, External: true},
}
st.Sets = []*model.AddressSet{{ID: 4, Name: "admins", Entries: []string{"192.168.1.0/28"}}}
pos := 0.0
rule := func(r model.Rule) {
pos++
r.ID, r.Position = int64(10+pos), pos
st.Rules = append(st.Rules, &r)
}
rule(model.Rule{Chain: "input", Match: model.Match{Sources: []string{"admins"}, Services: []string{"ssh"}}, Action: "accept", Comment: "SSH"})
rule(model.Rule{Chain: "input", Match: model.Match{InZones: []string{"lan", "guest"}, Services: []string{"dns"}}, Action: "accept"})
rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: "accept", Comment: "LAN out"})
rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"guest"}, OutZones: []string{"wan"}, Services: []string{"http", "https"}}, Action: "accept"})
rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"wan"}}, Action: "drop", Comment: "nothing in"})
rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"lan"}}, CtState: []string{"established"}, Action: "drop"}) // not for new
rule(model.Rule{Chain: "output", Match: model.Match{OutZones: []string{"wan"}}, Action: "drop", Disabled: true})
st.Forwards = []*model.PortForward{{ID: 30, Name: "web", Protocols: []string{"tcp"}, Ports: "443", To: "192.168.1.10"}}
st.Settings.Builtins.MSSClamp = true
return st
}
func find(m api.Matrix, from, to string) api.MatrixCell {
for _, c := range m.Cells {
if c.From == from && c.To == to {
return c
}
}
return api.MatrixCell{}
}
func TestMatrix(t *testing.T) {
m := Build(state())
if strings.Join(m.Zones, ",") != "guest,lan,wan" || len(m.Cells) != 3*3+3+3 {
t.Fatalf("zones %v, %d cells", m.Zones, len(m.Cells))
}
for _, c := range []struct {
from, to, verdict, decided string
exceptions int
}{
{"lan", "wan", "allowed", "rule forward #13 (LAN out)", 0},
{"guest", "wan", "blocked", "forward policy", 1}, // only web
{"wan", "lan", "blocked", "rule forward #15 (nothing in)", 2}, // antispoof, port forwards come first
{"lan", "guest", "blocked", "forward policy", 0}, // the established-only rule is no exception
{"lan", Firewall, "blocked", "input policy", 4}, // icmpv6, ping, ssh from admins, dns
{"wan", Firewall, "blocked", "input policy", 4}, // antispoof, icmpv6, dhcpv6, ssh from admins
{Firewall, "wan", "allowed", "output policy", 0}, // the drop is disabled
} {
got := find(m, c.from, c.to)
if got.Verdict != c.verdict || got.DecidedBy != c.decided || len(got.Exceptions) != c.exceptions {
t.Errorf("%s → %s: %s by %s, %d exceptions %+v; want %s by %s, %d", c.from, c.to, got.Verdict, got.DecidedBy, len(got.Exceptions), got.Exceptions, c.verdict, c.decided, c.exceptions)
}
}
g := find(m, "guest", "wan").Exceptions[0]
if g.Verdict != "allowed" || g.What != "http, https" || g.RuleID != 14 {
t.Errorf("the guest exception: %+v", g)
}
if e := find(m, "wan", "lan").Exceptions; e[0].Label != "built-in: antispoof" || e[1].Label != "port forwards" {
t.Errorf("wan → lan exceptions: %+v", e)
}
if _, ok := Cell(state(), Options{}, "lan", "nowhere"); ok {
t.Error("an unknown zone")
}
}
// Rules whose addresses all live behind another zone are left out of a
// pair; routed (unknown) networks, and mixed lists, stay.
func TestConnected(t *testing.T) {
st := state()
st.Sets = append(st.Sets, &model.AddressSet{ID: 5, Name: "guest_net", Entries: []string{"192.168.2.0/24"}},
&model.AddressSet{ID: 6, Name: "mixed", Entries: []string{"192.168.2.0/24", "10.20.30.0/24"}})
st.Rules = append(st.Rules,
&model.Rule{ID: 40, Chain: "forward", Position: 10, Match: model.Match{Sources: []string{"guest_net"}}, Action: "drop", Comment: "guests"},
&model.Rule{ID: 41, Chain: "forward", Position: 11, Match: model.Match{Destinations: []string{"192.168.2.5"}}, Action: "accept", Comment: "to a guest"},
&model.Rule{ID: 42, Chain: "forward", Position: 12, Match: model.Match{Sources: []string{"mixed"}}, Action: "drop", Comment: "mixed"})
o := Options{Connected: map[netip.Prefix]string{netip.MustParsePrefix("192.168.1.0/24"): "lan", netip.MustParsePrefix("192.168.2.0/24"): "guest"}}
ids := func(c api.MatrixCell) []int64 {
var out []int64
for _, e := range c.Exceptions {
out = append(out, e.RuleID)
}
return out
}
lanWan, _ := Cell(st, o, "lan", "wan")
if fmt.Sprint(ids(lanWan)) != "[]" {
t.Errorf("lan → wan: %v", ids(lanWan)) // decided by "LAN out" before the others anyway
}
lanGuest, _ := Cell(st, o, "lan", "guest")
if fmt.Sprint(ids(lanGuest)) != "[41 42]" {
t.Errorf("lan → guest: %v", ids(lanGuest)) // not "guests": their addresses are guest's
}
guestLan, _ := Cell(st, o, "guest", "lan")
if fmt.Sprint(ids(guestLan)) != "[40 42]" {
t.Errorf("guest → lan: %v", ids(guestLan)) // not "to a guest": it is in guest, not lan
}
}