A raw rule that matches and then accepts, drops or rejects (counter and log allowed, no interfaces) is an exception of every pair of its chain, shown as "raw: …", instead of unknown; one not for new connections is skipped like any rule. Interfaces, jumps, NAT, set and verdict maps stay unknown. The imported IPv6 neighbour discovery rule (hop limit 255) now reads as allowed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
8.9 KiB
Go
200 lines
8.9 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
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestRaw: raw rules that are a match and a verdict are read; others stay
|
|
// unknown.
|
|
func TestRaw(t *testing.T) {
|
|
for _, c := range []struct {
|
|
raw, verdict, short string
|
|
ok, forNew bool
|
|
}{
|
|
{"icmpv6 type { nd-router-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept", "allowed", "icmpv6", true, true},
|
|
{`tcp dport 22 counter log prefix "ssh: " drop comment "x"`, "blocked", "tcp dport 22", true, true},
|
|
{"ip saddr 10.0.0.1 reject with icmpx type admin-prohibited", "blocked", "ip saddr 10.0.0.1", true, true},
|
|
{"ct state established,related accept", "allowed", "ct state established,related", true, false},
|
|
{"ct state { new, untracked } tcp dport 80 accept", "allowed", "ct state { new, untracked } tcp dport 80", true, true},
|
|
{"counter accept", "allowed", "", true, true},
|
|
{`iifname "wan" tcp dport 22 accept`, "", "", false, false},
|
|
{"tcp dport vmap { 22 : accept }", "", "", false, false},
|
|
{"meta mark set 1 accept", "", "", false, false},
|
|
{"tcp dport 22 counter", "", "", false, false},
|
|
} {
|
|
rr := readRaw(c.raw)
|
|
if rr.ok != c.ok || c.ok && (rr.verdict != c.verdict || rr.short != c.short || rr.forNew != c.forNew) {
|
|
t.Errorf("%q: %+v", c.raw, rr)
|
|
}
|
|
}
|
|
// In a pair: an exception that lets ND in, not "unknown".
|
|
st := state()
|
|
st.Rules = append(st.Rules, &model.Rule{ID: 90, Chain: "input", Position: 0.5, Raw: "icmpv6 type { nd-router-solicit } ip6 hoplimit 255 accept"})
|
|
st.Sort()
|
|
e := find(Build(st), "wan", Firewall).Exceptions
|
|
found := false
|
|
for _, x := range e {
|
|
if x.RuleID == 90 {
|
|
found = x.Verdict == "allowed" && x.Short == "icmpv6" && strings.HasPrefix(x.What, "raw: icmpv6 type")
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("the raw ND rule: %+v", e)
|
|
}
|
|
}
|