Files
housecarl/internal/matrix/matrix.go
T
abrown38andClaude Opus 5.5 68ba2d06c5 Sets from Jarl: sets holding the addresses of names in Jarl
A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).

hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:51:55 +01:00

547 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, " · ")
}