A cell reads allow, block or partly (some new connections let through, the rest not), with its exceptions counted by what they do (+3 -1), and what they let through: services with the addresses they are limited to, under the grid in hc matrix and in each cell on the Matrix page. A zone to itself is marked: only traffic routed between its interfaces passes the firewall. The details list each rule's hits. hc matrix pads the grid by visible width: the colours' escape codes had thrown it out of line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
412 lines
13 KiB
Go
412 lines
13 KiB
Go
// 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 := walkCell(st, o, chain, from, to)
|
||
sum(&c)
|
||
if from == to && chain == model.ChainForward {
|
||
c.SameZone, c.Note = true, sameZoneNote(st.Zone(from))
|
||
}
|
||
return c
|
||
}
|
||
|
||
// sum fills in the overview: the summary, the exceptions counted by
|
||
// verdict, and what the allowing ones let through.
|
||
func sum(c *api.MatrixCell) {
|
||
c.Open = []string{}
|
||
seen := map[string]bool{}
|
||
for _, e := range c.Exceptions {
|
||
switch e.Verdict {
|
||
case "allowed":
|
||
c.Allowed++
|
||
if !seen[e.Short] {
|
||
seen[e.Short] = true
|
||
c.Open = append(c.Open, e.Short)
|
||
}
|
||
case "blocked":
|
||
c.Blocked++
|
||
}
|
||
}
|
||
c.Summary = c.Verdict
|
||
if c.Verdict == "blocked" && c.Allowed > 0 || c.Verdict == "allowed" && c.Blocked > 0 {
|
||
c.Summary = "partly"
|
||
}
|
||
}
|
||
|
||
// sameZoneNote says what of a zone's traffic to itself the firewall sees:
|
||
// only what it routes between the zone's interfaces.
|
||
func sameZoneNote(z *model.Zone) string {
|
||
if z == nil {
|
||
return ""
|
||
}
|
||
if len(z.Interfaces) == 1 && !strings.HasSuffix(z.Interfaces[0], "*") {
|
||
return fmt.Sprintf("traffic within %s stays on its own network: it passes the firewall only if routed back out of %s", z.Name, z.Interfaces[0])
|
||
}
|
||
return fmt.Sprintf("only traffic routed between %s's interfaces (%s) passes the firewall; within one of them it stays on its own network", z.Name, strings.Join(z.Interfaces, ", "))
|
||
}
|
||
|
||
func walkCell(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)", Short: "spoofed sources", Marks: []string{"b-antispoof", "b-antispoof6"}})
|
||
}
|
||
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",
|
||
Short: "ICMPv6", Marks: []string{"b-icmpv6", "b-icmpv6-nd", "b-icmpv6-mld"}})
|
||
}
|
||
if b.PingInternal && !external {
|
||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: ping", Verdict: "allowed", What: "ping (ICMP echo)", Short: "ping", Marks: []string{"b-ping"}})
|
||
}
|
||
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",
|
||
Short: "DHCPv6", Marks: []string{"b-dhcpv6-client"}})
|
||
}
|
||
case model.ChainForward:
|
||
if marks := forwards(st); len(marks) > 0 && external && !st.Zone(to).External {
|
||
n := fmt.Sprintf("%d port forward%s", len(marks), map[bool]string{true: "", false: "s"}[len(marks) == 1])
|
||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "port forwards", Verdict: "allowed",
|
||
What: n + " to inside hosts", Short: n, Marks: marks})
|
||
}
|
||
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",
|
||
Short: "ICMPv6", Marks: []string{"b-icmpv6"}})
|
||
}
|
||
}
|
||
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"
|
||
if policy == model.PolicyDrop {
|
||
c.Marks = []string{"p-" + chain} // only a drop policy is counted
|
||
}
|
||
return c
|
||
}
|
||
|
||
// forwards returns the counters' marks of the port forwards that send
|
||
// connections inside.
|
||
func forwards(st *model.State) []string {
|
||
var marks []string
|
||
for _, f := range st.Forwards {
|
||
if !f.Disabled && f.To != "" {
|
||
marks = append(marks, fmt.Sprintf("f%d", f.ID))
|
||
}
|
||
}
|
||
return marks
|
||
}
|
||
|
||
// 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 {
|
||
mark := []string{fmt.Sprintf("r%d", r.ID)}
|
||
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,
|
||
Short: "a raw rule", Marks: mark})
|
||
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,
|
||
Short: short(r, what), Marks: mark})
|
||
continue
|
||
}
|
||
c.Verdict, c.DecidedBy, c.RuleID, c.Marks = v, "rule "+r.Label(), r.ID, mark
|
||
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
|
||
}
|
||
|
||
// short sums up an exception for the overview: its services (ICMP types
|
||
// as just icmp or icmpv6), with the addresses it is limited to, which
|
||
// matter as much ("tcp/22 from trusted"); times and rates are left to
|
||
// the details.
|
||
func short(r *model.Rule, what string) string {
|
||
var parts []string
|
||
if len(r.Services) > 0 {
|
||
var svcs []string
|
||
seen := map[string]bool{}
|
||
for _, s := range r.Services {
|
||
if p, _, ok := strings.Cut(s, "/"); ok && (p == "icmp" || p == "icmpv6") {
|
||
s = p
|
||
}
|
||
if !seen[s] {
|
||
seen[s] = true
|
||
svcs = append(svcs, s)
|
||
}
|
||
}
|
||
parts = append(parts, strings.Join(svcs, ", "))
|
||
}
|
||
for _, c := range strings.Split(what, " · ") {
|
||
if strings.HasPrefix(c, "from ") || strings.HasPrefix(c, "to ") {
|
||
parts = append(parts, c)
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
first, _, _ := strings.Cut(what, " · ")
|
||
return first
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
// 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, " · ")
|
||
}
|