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>
113 lines
5.2 KiB
Go
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
|
|
}
|
|
}
|