diff --git a/docs/rules.md b/docs/rules.md index 5dd4e3f..20ab948 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -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 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 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 diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index 66aeaa2..65456c0 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -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 diff --git a/internal/matrix/matrix_test.go b/internal/matrix/matrix_test.go index ae6dad2..2be57c7 100644 --- a/internal/matrix/matrix_test.go +++ b/internal/matrix/matrix_test.go @@ -158,3 +158,42 @@ func TestSummary(t *testing.T) { 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) + } +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 6dad8bf..8978bd1 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -344,6 +344,7 @@ not_contains "wg0_net" run "the internet to the firewall" 0 H matrix wan firewall contains "blocked *sources that can't come from outside" contains "allowed *udp/51820" +contains "allowed *raw: icmpv6 type" # ---- statistics (sampled every 3 seconds here)