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) } }