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>
This commit is contained in:
1 parent
e9cbb3e4e9
commit
b6f31c86a6
17 files changed
+725
-2
No files matched your search
@@ -0,0 +1,323 @@
|
||||
// Package matrix works out what the rules do to new connections between
|
||||
// zones, for an overview: for each pair it walks the rules as nftables
|
||||
// would, in order. A rule that applies to the whole pair decides it; one
|
||||
// that applies to some of its traffic (some addresses, services, times,
|
||||
// rates…) is an exception, and the walk goes on; what is left falls to
|
||||
// the chain's policy.
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Firewall stands for the machine itself in the matrix.
|
||||
const Firewall = "firewall"
|
||||
|
||||
// Options tell Build about the machine.
|
||||
type Options struct {
|
||||
// Connected maps the networks directly connected to the firewall's
|
||||
// interfaces to their zones. A rule whose addresses all lie in other
|
||||
// zones' networks can't apply to a pair, and is left out of it.
|
||||
Connected map[netip.Prefix]string
|
||||
}
|
||||
|
||||
// Build works out the matrix of a configuration.
|
||||
func Build(st *model.State, opts ...Options) api.Matrix {
|
||||
var o Options
|
||||
if len(opts) > 0 {
|
||||
o = opts[0]
|
||||
}
|
||||
m := api.Matrix{Zones: []string{}, Cells: []api.MatrixCell{}}
|
||||
for _, z := range st.Zones {
|
||||
m.Zones = append(m.Zones, z.Name)
|
||||
}
|
||||
for _, from := range m.Zones {
|
||||
for _, to := range m.Zones {
|
||||
m.Cells = append(m.Cells, cell(st, o, model.ChainForward, from, to))
|
||||
}
|
||||
m.Cells = append(m.Cells, cell(st, o, model.ChainInput, from, Firewall))
|
||||
}
|
||||
for _, to := range m.Zones {
|
||||
m.Cells = append(m.Cells, cell(st, o, model.ChainOutput, Firewall, to))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Cell returns one pair (from or to may be Firewall).
|
||||
func Cell(st *model.State, o Options, from, to string) (api.MatrixCell, bool) {
|
||||
known := func(z string) bool { return st.Zone(z) != nil }
|
||||
switch {
|
||||
case from == Firewall && known(to):
|
||||
return cell(st, o, model.ChainOutput, from, to), true
|
||||
case to == Firewall && known(from):
|
||||
return cell(st, o, model.ChainInput, from, to), true
|
||||
case known(from) && known(to):
|
||||
return cell(st, o, model.ChainForward, from, to), true
|
||||
}
|
||||
return api.MatrixCell{}, false
|
||||
}
|
||||
|
||||
func cell(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)
|
||||
external := fromZone != nil && fromZone.External
|
||||
// What comes in passes the early chain first.
|
||||
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)"})
|
||||
}
|
||||
if decided := walk(st, o, &c, model.ChainEarly, from, ""); decided {
|
||||
return c
|
||||
}
|
||||
}
|
||||
// The built-ins that let some new connections in.
|
||||
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"})
|
||||
}
|
||||
if b.PingInternal && !external {
|
||||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: ping", Verdict: "allowed", What: "ping (ICMP echo)"})
|
||||
}
|
||||
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"})
|
||||
}
|
||||
case model.ChainForward:
|
||||
if n := forwards(st); n > 0 && external && !st.Zone(to).External {
|
||||
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])})
|
||||
}
|
||||
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"})
|
||||
}
|
||||
}
|
||||
if walk(st, o, &c, chain, from, to) {
|
||||
return c
|
||||
}
|
||||
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"
|
||||
return c
|
||||
}
|
||||
|
||||
func forwards(st *model.State) int {
|
||||
n := 0
|
||||
for _, f := range st.Forwards {
|
||||
if !f.Disabled && f.To != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// walk goes through a chain's rules for the pair, as nftables checks
|
||||
// them (those that check every packet first). It reports whether a rule
|
||||
// decided the whole pair.
|
||||
func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string) bool {
|
||||
var rules []*model.Rule
|
||||
for _, every := range []bool{true, false} {
|
||||
for _, r := range st.Rules {
|
||||
if r.Chain == chain && !r.Disabled && r.ChecksEveryPacket() == every {
|
||||
rules = append(rules, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range rules {
|
||||
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})
|
||||
continue
|
||||
}
|
||||
if !zones(r, from, to) || !forNew(r) {
|
||||
continue
|
||||
}
|
||||
// Addresses that all live behind other zones can't come from (or
|
||||
// go to) this pair's.
|
||||
if !r.NotSources && elsewhere(st, o, r.Sources, from) || !r.NotDests && to != Firewall && to != "" && elsewhere(st, o, r.Destinations, to) {
|
||||
continue
|
||||
}
|
||||
if chain == model.ChainEarly && r.Action != model.ActionDrop && r.Action != model.ActionReject {
|
||||
continue // an early accept decides nothing: input or forward still check
|
||||
}
|
||||
v := verdictOf(r.Action)
|
||||
what := conditions(st, r)
|
||||
if r.Action == model.ActionLog || what != "" {
|
||||
if what == "" {
|
||||
what = "everything"
|
||||
}
|
||||
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: v, What: what})
|
||||
continue
|
||||
}
|
||||
c.Verdict, c.DecidedBy, c.RuleID = v, "rule "+r.Label(), r.ID
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func verdictOf(action string) string {
|
||||
switch action {
|
||||
case model.ActionAccept: // and the accept policy
|
||||
return "allowed"
|
||||
case model.ActionLog:
|
||||
return "logged"
|
||||
}
|
||||
return "blocked"
|
||||
}
|
||||
|
||||
// elsewhere reports whether every address of refs (addresses, sets) lies
|
||||
// in networks connected to zones other than zone: then traffic of zone
|
||||
// can't have them. An empty list, a dynamic set, or an address the
|
||||
// firewall isn't directly connected to (a routed network) might.
|
||||
func elsewhere(st *model.State, o Options, refs []string, zone string) bool {
|
||||
if len(refs) == 0 || len(o.Connected) == 0 || zone == Firewall {
|
||||
return false
|
||||
}
|
||||
var entries []string
|
||||
for _, r := range refs {
|
||||
if s := st.Set(r); s != nil {
|
||||
if s.Dynamic {
|
||||
return false
|
||||
}
|
||||
entries = append(entries, s.Entries...)
|
||||
} else {
|
||||
entries = append(entries, r)
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
a, err := model.ParseAddress(e)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
first, last := a.Prefix.Masked().Addr(), lastAddr(a.Prefix)
|
||||
if a.First.IsValid() {
|
||||
first, last = a.First, a.End
|
||||
}
|
||||
owner := ""
|
||||
for net, z := range o.Connected {
|
||||
if net.Contains(first) && net.Contains(last) {
|
||||
owner = z
|
||||
break
|
||||
}
|
||||
}
|
||||
if owner == "" || strings.EqualFold(owner, zone) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// lastAddr is a prefix's last address.
|
||||
func lastAddr(p netip.Prefix) netip.Addr {
|
||||
b := p.Masked().Addr().AsSlice()
|
||||
for i := p.Bits(); i < len(b)*8; i++ {
|
||||
b[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
a, _ := netip.AddrFromSlice(b)
|
||||
return a
|
||||
}
|
||||
|
||||
// zones reports whether a rule's zones take in the pair.
|
||||
func zones(r *model.Rule, from, to string) bool {
|
||||
in := func(list []string, not bool, z string) bool {
|
||||
if len(list) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(x, z) {
|
||||
return !not
|
||||
}
|
||||
}
|
||||
return not
|
||||
}
|
||||
if from == Firewall {
|
||||
if len(r.InZones) > 0 {
|
||||
return false
|
||||
}
|
||||
} else if !in(r.InZones, r.NotIn, from) {
|
||||
return false
|
||||
}
|
||||
if to == "" || to == Firewall {
|
||||
return len(r.OutZones) == 0
|
||||
}
|
||||
return in(r.OutZones, r.NotOut, to)
|
||||
}
|
||||
|
||||
// forNew reports whether a rule can match a new connection's first
|
||||
// packet (its connection states allow "new").
|
||||
func forNew(r *model.Rule) bool {
|
||||
if len(r.CtState) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, s := range r.CtState {
|
||||
if s == "new" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var parts []string
|
||||
list := func(not bool, xs []string) string {
|
||||
s := strings.Join(xs, ", ")
|
||||
if not {
|
||||
return "any but " + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
if len(r.Sources) > 0 {
|
||||
parts = append(parts, "from "+list(r.NotSources, r.Sources))
|
||||
}
|
||||
if len(r.Destinations) > 0 {
|
||||
parts = append(parts, "to "+list(r.NotDests, r.Destinations))
|
||||
}
|
||||
if len(r.Services) > 0 {
|
||||
parts = append(parts, strings.Join(r.Services, ", "))
|
||||
}
|
||||
if r.Family != "" {
|
||||
parts = append(parts, r.Family+" only")
|
||||
}
|
||||
if len(r.CtStatus) > 0 {
|
||||
parts = append(parts, "connections that were "+strings.Join(r.CtStatus, ", ")+"'d")
|
||||
}
|
||||
if len(r.Days) > 0 {
|
||||
parts = append(parts, strings.Join(r.Days, ", "))
|
||||
}
|
||||
if r.HourFrom != "" {
|
||||
parts = append(parts, r.HourFrom+"–"+r.HourTo)
|
||||
}
|
||||
if l := r.Limit; l != nil {
|
||||
unit := ""
|
||||
if l.Unit != "" && l.Unit != "packets" {
|
||||
unit = " " + l.Unit
|
||||
}
|
||||
rate := fmt.Sprintf("%d%s/%s", l.Rate, unit, l.Per)
|
||||
if l.Over {
|
||||
parts = append(parts, "beyond "+rate)
|
||||
} else {
|
||||
parts = append(parts, "within "+rate)
|
||||
}
|
||||
}
|
||||
if r.ConnLimit > 0 {
|
||||
parts = append(parts, fmt.Sprintf("sources with over %d connections", r.ConnLimit))
|
||||
}
|
||||
if q := r.Quota; q != nil {
|
||||
if q.Over {
|
||||
parts = append(parts, "once its quota is used up")
|
||||
} else {
|
||||
parts = append(parts, "while its quota lasts")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user