Matrix: read raw rules that are a match and a verdict

A raw rule that matches and then accepts, drops or rejects (counter and
log allowed, no interfaces) is an exception of every pair of its chain,
shown as "raw: …", instead of unknown; one not for new connections is
skipped like any rule. Interfaces, jumps, NAT, set and verdict maps
stay unknown. The imported IPv6 neighbour discovery rule (hop limit
255) now reads as allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 19:18:33 +01:00
1 parent fd2fc3b6f0
commit 566c07cbc7
4 files changed
+184 -2

No files matched your search

+137 -2
View File
@@ -9,6 +9,7 @@ package matrix
import (
"fmt"
"net/netip"
"regexp"
"strings"
"housecarl/internal/api"
@@ -185,8 +186,20 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string)
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})
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) {
@@ -321,6 +334,128 @@ func forNew(r *model.Rule) bool {
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