// 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" "regexp" "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 != "" { rr := readRaw(r.Raw) switch { case !rr.ok: 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}) case !rr.forNew || chain == model.ChainEarly && rr.verdict == "allowed": // Not for new connections; an early accept decides nothing. case rr.match == "": c.Verdict, c.DecidedBy, c.RuleID, c.Marks = rr.verdict, "rule "+r.Label(), r.ID, mark return true default: c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: rr.verdict, What: "raw: " + rr.match, Short: rr.short, 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 or Jarl 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.Runtime() { 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 } // raw is what the matrix can read of a raw rule. type raw struct { ok bool // read: it applies to every pair of its chain forNew bool // it can match a new connection verdict string // allowed or blocked match string // what it matches ("" for everything) short string } // rawStops are words that make a raw rule's reach or effect unclear to // the matrix: interfaces (which zones?), jumps, NAT, changes, verdict maps. var rawStops = map[string]bool{ "iifname": true, "oifname": true, "iif": true, "oif": true, "iiftype": true, "oiftype": true, "jump": true, "goto": true, "return": true, "vmap": true, "queue": true, "continue": true, "dnat": true, "snat": true, "masquerade": true, "redirect": true, "tproxy": true, "set": true, "notrack": true, "dup": true, "fwd": true, "synproxy": true, "flow": true, } // readRaw reads a raw rule that is a match and then accept, drop or // reject: the verdict is the last word (but a reject's "with …"); counter // and log only count and log. func readRaw(text string) raw { toks := fields(text) for i, t := range toks { if t == "comment" { toks = toks[:i] break } } verdict := -1 for i, t := range toks { if strings.HasPrefix(t, `"`) { continue } w := strings.Trim(t, ",{}") if rawStops[w] { return raw{} } if w == "accept" || w == "drop" || w == "reject" { verdict = i } } if verdict < 0 || verdict != len(toks)-1 && !(toks[verdict] == "reject" && toks[verdict+1] == "with") { return raw{} } rr := raw{ok: true, forNew: true, verdict: verdictOf(toks[verdict])} var match []string for i := 0; i < verdict; i++ { switch toks[i] { case "counter": continue case "log": // Its options run up to the next match. for i+1 < verdict && map[string]bool{"prefix": true, "level": true, "group": true, "flags": true}[toks[i+1]] { i += 2 } continue } match = append(match, toks[i]) } rr.match = strings.Join(match, " ") // ct state without new: not for new connections. The states are one // word ("established,related") or a set ("{ established, related }"). for i := 0; i+2 < len(match); i++ { if match[i] != "ct" || match[i+1] != "state" { continue } list := match[i+2] if list == "{" || strings.HasPrefix(list, "{") { var b []string for j := i + 2; j < len(match); j++ { b = append(b, match[j]) if strings.HasSuffix(match[j], "}") { break } } list = strings.Join(b, " ") } rr.forNew = false for _, st := range strings.FieldsFunc(list, func(c rune) bool { return c == ',' || c == ' ' || c == '{' || c == '}' }) { rr.forNew = rr.forNew || st == "new" } } rr.short = rr.match if m := icmpRe.FindStringSubmatch(rr.match); m != nil { rr.short = m[1] } if len(rr.short) > 40 { rr.short = rr.short[:39] + "…" } return rr } // fields splits a rule into words, a quoted string being one. func fields(s string) []string { var out []string var cur strings.Builder quoted := false for _, c := range s { switch { case c == '"': quoted = !quoted cur.WriteRune(c) case !quoted && (c == ' ' || c == '\t'): if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() } default: cur.WriteRune(c) } } if cur.Len() > 0 { out = append(out, cur.String()) } return out } // icmpRe finds an ICMP type match in a raw rule: shown as just icmp or // icmpv6. var icmpRe = regexp.MustCompile(`\b(icmpv6|icmp) type\b`) // 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, " · ") }