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>
547 lines
17 KiB
Go
547 lines
17 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"
|
||
"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, " · ")
|
||
}
|