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:
1 parent
fd2fc3b6f0
commit
566c07cbc7
4 files changed
+184
-2
No files matched your search
@@ -332,6 +332,13 @@ The hits are the rule's packets since housecarld started, in all of its
|
|||||||
traffic: a rule without zones counts for every pair it covers. They are
|
traffic: a rule without zones counts for every pair it covers. They are
|
||||||
how you tell an exception that is used from one that isn't.
|
how you tell an exception that is used from one that isn't.
|
||||||
|
|
||||||
|
A raw rule is read when it is a match followed by `accept`, `drop` or
|
||||||
|
`reject` (with `counter` and `log` if you like): it counts as an exception
|
||||||
|
for every pair of its chain, shown as `raw: …`. One that matches
|
||||||
|
interfaces (the matrix can't tell which zones), jumps, translates, sets
|
||||||
|
something or uses a verdict map shows as **unknown**: check it yourself.
|
||||||
|
Raw rules have hits only if they say `counter`.
|
||||||
|
|
||||||
A rule whose addresses all lie in networks directly connected to other
|
A rule whose addresses all lie in networks directly connected to other
|
||||||
zones is left out of a pair: traffic between those zones can't have them
|
zones is left out of a pair: traffic between those zones can't have them
|
||||||
(`from wg_net` doesn't concern traffic coming in on the LAN). Routed
|
(`from wg_net` doesn't concern traffic coming in on the LAN). Routed
|
||||||
|
|||||||
+137
-2
@@ -9,6 +9,7 @@ package matrix
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"housecarl/internal/api"
|
"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 {
|
for _, r := range rules {
|
||||||
mark := []string{fmt.Sprintf("r%d", r.ID)}
|
mark := []string{fmt.Sprintf("r%d", r.ID)}
|
||||||
if r.Raw != "" {
|
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,
|
rr := readRaw(r.Raw)
|
||||||
Short: "a raw rule", Marks: mark})
|
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
|
continue
|
||||||
}
|
}
|
||||||
if !zones(r, from, to) || !forNew(r) {
|
if !zones(r, from, to) || !forNew(r) {
|
||||||
@@ -321,6 +334,128 @@ func forNew(r *model.Rule) bool {
|
|||||||
return false
|
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
|
// 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
|
// 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
|
// matter as much ("tcp/22 from trusted"); times and rates are left to
|
||||||
|
|||||||
@@ -158,3 +158,42 @@ func TestSummary(t *testing.T) {
|
|||||||
t.Errorf("a drop policy's mark: %v", c.Marks)
|
t.Errorf("a drop policy's mark: %v", c.Marks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRaw: raw rules that are a match and a verdict are read; others stay
|
||||||
|
// unknown.
|
||||||
|
func TestRaw(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
raw, verdict, short string
|
||||||
|
ok, forNew bool
|
||||||
|
}{
|
||||||
|
{"icmpv6 type { nd-router-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept", "allowed", "icmpv6", true, true},
|
||||||
|
{`tcp dport 22 counter log prefix "ssh: " drop comment "x"`, "blocked", "tcp dport 22", true, true},
|
||||||
|
{"ip saddr 10.0.0.1 reject with icmpx type admin-prohibited", "blocked", "ip saddr 10.0.0.1", true, true},
|
||||||
|
{"ct state established,related accept", "allowed", "ct state established,related", true, false},
|
||||||
|
{"ct state { new, untracked } tcp dport 80 accept", "allowed", "ct state { new, untracked } tcp dport 80", true, true},
|
||||||
|
{"counter accept", "allowed", "", true, true},
|
||||||
|
{`iifname "wan" tcp dport 22 accept`, "", "", false, false},
|
||||||
|
{"tcp dport vmap { 22 : accept }", "", "", false, false},
|
||||||
|
{"meta mark set 1 accept", "", "", false, false},
|
||||||
|
{"tcp dport 22 counter", "", "", false, false},
|
||||||
|
} {
|
||||||
|
rr := readRaw(c.raw)
|
||||||
|
if rr.ok != c.ok || c.ok && (rr.verdict != c.verdict || rr.short != c.short || rr.forNew != c.forNew) {
|
||||||
|
t.Errorf("%q: %+v", c.raw, rr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In a pair: an exception that lets ND in, not "unknown".
|
||||||
|
st := state()
|
||||||
|
st.Rules = append(st.Rules, &model.Rule{ID: 90, Chain: "input", Position: 0.5, Raw: "icmpv6 type { nd-router-solicit } ip6 hoplimit 255 accept"})
|
||||||
|
st.Sort()
|
||||||
|
e := find(Build(st), "wan", Firewall).Exceptions
|
||||||
|
found := false
|
||||||
|
for _, x := range e {
|
||||||
|
if x.RuleID == 90 {
|
||||||
|
found = x.Verdict == "allowed" && x.Short == "icmpv6" && strings.HasPrefix(x.What, "raw: icmpv6 type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("the raw ND rule: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -344,6 +344,7 @@ not_contains "wg0_net"
|
|||||||
run "the internet to the firewall" 0 H matrix wan firewall
|
run "the internet to the firewall" 0 H matrix wan firewall
|
||||||
contains "blocked *sources that can't come from outside"
|
contains "blocked *sources that can't come from outside"
|
||||||
contains "allowed *udp/51820"
|
contains "allowed *udp/51820"
|
||||||
|
contains "allowed *raw: icmpv6 type"
|
||||||
|
|
||||||
# ---- statistics (sampled every 3 seconds here)
|
# ---- statistics (sampled every 3 seconds here)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user