diff --git a/CHANGELOG.md b/CHANGELOG.md index b87b0a9..00b8687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to Housecarl are documented here. The format follows ### Added +- **A clearer zone matrix.** A cell reads allow, block, or **partly** + (some new connections let through, the rest not), with the exceptions + counted by what they do (`+3 -1` instead of `+4`), and what they let + through: services with the addresses they're limited to (`ssh from + trusted`, `5 port forwards`), under the grid in `hc matrix`, in each + cell on the Matrix page. A zone to itself is marked (only traffic routed + between its interfaces passes the firewall). The details list each + rule's hits. API: `summary`, `allowed`, `blocked`, `open`, + `same_zone`, `note`, `hits`, and `short` and `hits` per exception. - **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md): Housecarl's ruleset is loaded beside the firewall you have but blocks, rejects and translates nothing. Drops and rejects are counted and accept @@ -42,6 +51,8 @@ All notable changes to Housecarl are documented here. The format follows ### Fixed +- `hc matrix` in a terminal: the colours' escape codes threw the columns + out of line. - `hc counters` (`GET /counters`) kept listing the objects of a configuration replaced since housecarld started (`hc import --replace` gives every object a new ID), beside the current ones; the diff --git a/cmd/hc/main_test.go b/cmd/hc/main_test.go index 116fc77..f72d642 100644 --- a/cmd/hc/main_test.go +++ b/cmd/hc/main_test.go @@ -1,8 +1,11 @@ package main import ( + "regexp" + "strings" "testing" + "housecarl/internal/matrix" "housecarl/internal/model" ) @@ -40,3 +43,26 @@ func TestLimitAndDescribe(t *testing.T) { t.Errorf("editList: %v", got) } } + +// TestMatrixAligned: the grid's columns line up in colour too (the +// escape codes take no room on screen). +func TestMatrixAligned(t *testing.T) { + st := model.NewState() + st.Zones = []*model.Zone{{ID: 1, Name: "lan", Interfaces: []string{"eth1"}}, {ID: 2, Name: "wan", Interfaces: []string{"wan"}, External: true}} + st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainForward, Position: 1, Action: model.ActionAccept, Match: model.Match{InZones: []string{"lan"}, Services: []string{"https"}}}} + st.Settings.InputPolicy, st.Settings.ForwardPolicy = model.PolicyDrop, model.PolicyDrop + var b strings.Builder + writeMatrix(&b, matrix.Build(st), true) + esc := regexp.MustCompile("\x1b\\[[0-9]+m") + lines := strings.Split(esc.ReplaceAllString(b.String(), ""), "\n") + col := strings.Index(lines[0], "wan") + for _, l := range lines[1:4] { + if r := []rune(l); len(r) <= col || r[col-1] != ' ' || r[col] == ' ' { + t.Errorf("misaligned at column %d:\n%s", col, strings.Join(lines[:4], "\n")) + break + } + } + if !strings.Contains(b.String(), "\x1b[33mpartly +1") || !strings.Contains(b.String(), "lan → wan\thttps") { + t.Errorf("no partly cell, or no list of what's open:\n%s", b.String()) + } +} diff --git a/cmd/hc/traffic.go b/cmd/hc/traffic.go index 9b709f0..97f9ddc 100644 --- a/cmd/hc/traffic.go +++ b/cmd/hc/traffic.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/spf13/cobra" @@ -249,42 +250,112 @@ such as some services or addresses). With FROM and TO (a zone, or } func printMatrix(w io.Writer, m api.Matrix) { + writeMatrix(w, m, isTerminal(os.Stdout) && flagOutput == "table") +} + +// writeMatrix writes the grid, in colour on a terminal. +func writeMatrix(w io.Writer, m api.Matrix, color bool) { cols := append(append([]string{}, m.Zones...), "firewall") cells := map[[2]string]api.MatrixCell{} for _, c := range m.Cells { cells[[2]string{c.From, c.To}] = c } - color := isTerminal(os.Stdout) && flagOutput == "table" - fmt.Fprint(w, "FROM \\ TO") - for _, c := range cols { - fmt.Fprintf(w, "\t%s", c) - } - fmt.Fprintln(w) + // The grid is padded by hand: tabwriter would count the colours' + // escape codes as text, and misalign the columns. + grid := [][]string{append([]string{"FROM \\ TO"}, cols...)} + kinds := [][]string{make([]string, len(cols)+1)} + same := false for _, from := range cols { - fmt.Fprint(w, from) + row, kind := []string{from}, []string{""} for _, to := range cols { c, ok := cells[[2]string{from, to}] - text := "-" - if ok { - text = map[string]string{"allowed": "allow", "blocked": "block"}[c.Verdict] - if n := len(c.Exceptions); n > 0 { - text += fmt.Sprintf(" +%d", n) - } - if color { - text = map[string]string{"allowed": "\x1b[32m", "blocked": "\x1b[31m"}[c.Verdict] + text + "\x1b[0m" - } + if !ok { + row, kind = append(row, "-"), append(kind, "") + continue } - fmt.Fprintf(w, "\t%s", text) + text := map[string]string{"allowed": "allow", "blocked": "block", "partly": "partly"}[c.Summary] + if c.Allowed > 0 { + text += fmt.Sprintf(" +%d", c.Allowed) + } + if c.Blocked > 0 { + text += fmt.Sprintf(" -%d", c.Blocked) + } + if c.SameZone { + text += " *" + same = true + } + row, kind = append(row, text), append(kind, c.Summary) } - fmt.Fprintln(w) + grid, kinds = append(grid, row), append(kinds, kind) + } + width := make([]int, len(cols)+1) + for _, row := range grid { + for i, t := range row { + width[i] = max(width[i], utf8.RuneCountInString(t)) + } + } + paint := map[string]string{"allowed": "\x1b[32m", "blocked": "\x1b[31m", "partly": "\x1b[33m"} + for r, row := range grid { + var b strings.Builder + for i, t := range row { + pad := strings.Repeat(" ", width[i]-utf8.RuneCountInString(t)) + if i == len(row)-1 { + pad = "" + } else { + pad += " " + } + if p := paint[kinds[r][i]]; color && p != "" { + t = p + t + "\x1b[0m" + } + b.WriteString(t + pad) + } + fmt.Fprintln(w, b.String()) + } + // What the partly open pairs let through. + var open [][2]string + for _, from := range cols { + for _, to := range cols { + c, ok := cells[[2]string{from, to}] + if !ok || len(c.Open) == 0 || c.SameZone { + continue + } + items := c.Open + more := "" + if len(items) > 6 { + items, more = items[:6], fmt.Sprintf("; %d more", len(c.Open)-6) + } + open = append(open, [2]string{from + " → " + to, strings.Join(items, "; ") + more}) + } + } + if len(open) > 0 { + fmt.Fprintln(w, "\nLet through for part of the traffic:") + for _, o := range open { + fmt.Fprintf(w, " %s\t%s\n", o[0], o[1]) + } + } + fmt.Fprintln(w, "\nallow, block: every new connection; partly: some are let through, some not.") + fmt.Fprintln(w, "+N: rules that let part of the traffic through; -N: rules that block part of it. 'hc matrix FROM TO' lists them.") + if same { + fmt.Fprintln(w, "*: a zone to itself: only traffic routed between its interfaces passes the firewall.") } - fmt.Fprintln(w, "\nallow/block: new connections no exception covers; +N: rules deciding for part of the traffic first ('hc matrix FROM TO')") } func printCell(w io.Writer, c api.MatrixCell) { - fmt.Fprintf(w, "%s → %s (the %s chain), new connections:\n", c.From, c.To, c.Chain) - for _, e := range c.Exceptions { - fmt.Fprintf(w, " %s\t%s\t%s\n", e.Verdict, e.What, e.Label) + summary := map[string]string{"allowed": "all allowed", "blocked": "all blocked", "partly": "partly allowed"}[c.Summary] + fmt.Fprintf(w, "%s → %s (the %s chain), new connections: %s\n", c.From, c.To, c.Chain, summary) + if c.Note != "" { + fmt.Fprintf(w, " (%s)\n", c.Note) } - fmt.Fprintf(w, " %s\tthe rest\t%s\n", c.Verdict, c.DecidedBy) + hits := func(h *uint64) string { + if h == nil { + return "-" + } + return fmt.Sprintf("%d", *h) + } + fmt.Fprintln(w, " VERDICT\tWHAT\tBY\tHITS") + for _, e := range c.Exceptions { + fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", e.Verdict, e.What, e.Label, hits(e.Hits)) + } + fmt.Fprintf(w, " %s\tthe rest\t%s\t%s\n", c.Verdict, c.DecidedBy, hits(c.Hits)) + fmt.Fprintln(w, "\nHits: each rule's packets since housecarld started, in all its traffic (a rule can cover several pairs).") } diff --git a/docs/api.md b/docs/api.md index f22d15a..33c6d49 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,7 +77,7 @@ Creating answers 201, other changes 200. | `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `mode` (`enforcing`, or `shadow`: the ruleset blocks nothing, [shadow.md](shadow.md)), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) | | `GET /status` | `build`, `started`, `mode` (`enforcing` or `shadow`), `shadow_position` (`after` or `before`, in shadow mode), `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) | | `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` | -| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `verdict` (allowed, blocked), `decided_by`, `rule_id`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`) ([rules.md](rules.md#the-zone-matrix)) | +| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `summary` (allowed, blocked, partly), `verdict` (what the rest gets: allowed, blocked), `decided_by`, `rule_id`, `hits`, `allowed` and `blocked` (exceptions by verdict), `open` (what the allowing exceptions let through, in short), `same_zone` and `note`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`, `short`, `hits`). `hits` are the rule's or policy's packets since housecarld started, all its traffic (missing: no counter) ([rules.md](rules.md#the-zone-matrix)) | | `GET /ruleset[?enforcing=1]` | the nftables ruleset Housecarl builds, as text. In shadow mode it is the shadow ruleset; `enforcing=1` gives the one loaded once shadow mode is off | | `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started | | `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` | diff --git a/docs/cli.md b/docs/cli.md index fbe6fab..9c4850c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -139,7 +139,7 @@ by name. | `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) | | `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)); not in shadow mode | | `stats [--since 24h]` | traffic per zone, drops, the busiest rules, blocked sources, devices; `stats status`, `stats traffic`, `stats top devices\|blocked\|tried\|quota ID`, `stats rules [--unused]`, `stats connections` ([stats.md](stats.md#the-cli)) | -| `matrix [FROM TO]` | what the rules do to new connections between zones; with FROM and TO, that pair's rules in order ([rules.md](rules.md#the-zone-matrix)) | +| `matrix [FROM TO]` | what the rules do to new connections between zones (allow, block or partly), and what is let through; with FROM and TO, that pair's rules in order, with their hits ([rules.md](rules.md#the-zone-matrix)) | | `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) | ### zone, set, service diff --git a/docs/rules.md b/docs/rules.md index fd719cf..5dd4e3f 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -278,31 +278,60 @@ chain; from it: output; zone to zone: forward). ``` $ hc matrix -FROM \ TO guest lan wan firewall -guest block block block +1 block +3 -lan block block allow block +4 -wan block +2 block +2 block +1 block +3 -firewall allow allow allow - +FROM \ TO guest lan wan firewall +guest block * block partly +1 partly +4 +lan block block * allow partly +4 +wan partly +1 -1 partly +1 -1 block -1 * partly +3 -1 +firewall allow allow allow - + +Let through for part of the traffic: + guest → wan http, https + guest → firewall ICMPv6; ping; ssh from admins; dns + lan → firewall ICMPv6; ping; ssh from admins; dns + wan → guest 1 port forward + wan → lan 1 port forward + wan → firewall ICMPv6; DHCPv6; ssh from admins + +allow, block: every new connection; partly: some are let through, some not. ++N: rules that let part of the traffic through; -N: rules that block part of it. 'hc matrix FROM TO' lists them. +*: a zone to itself: only traffic routed between its interfaces passes the firewall. ``` Each cell walks that pair's rules in order, as nftables would, with the built-ins that act on new connections (ping from internal zones, ICMPv6, the DHCPv6 client, antispoofing, port forwards): -* a rule that applies to the whole pair decides it ("allow", "block"); +* a rule that applies to the whole pair decides it; * a rule that applies to part of its traffic (some addresses, services, - times, rates, a quota) is an **exception** (+N), and the walk goes on; + times, rates, a quota) is an **exception**, and the walk goes on; * what's left falls to the deciding rule or the chain's policy. +A cell reads **allow** or **block** when every new connection gets the +same, and **partly** when exceptions let some through while the rest is +blocked (or block some while the rest is allowed). **+N** counts the +exceptions that let traffic through, **-N** those that block it. Under +the grid (in the web UI, in each cell) is what the exceptions let +through: their services, with the addresses they are limited to. + +A zone to itself (`*`, faded in the web UI) is mostly traffic that never +reaches the firewall: within one interface's network it goes straight +from host to host. Only what is routed between the zone's interfaces (or +back out of the one it came in on) passes. + `hc matrix guest wan` (or clicking the cell) lists the exceptions in -order: +order, with each rule's hits: ``` -guest → wan (the forward chain), new connections: - allowed http, https rule forward #14 - blocked the rest forward policy +guest → wan (the forward chain), new connections: partly allowed + VERDICT WHAT BY HITS + allowed http, https rule forward #14 1520 + blocked the rest forward policy 37 ``` +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 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/api/types.go b/internal/api/types.go index 9c261d4..8caefb5 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -467,6 +467,26 @@ type MatrixCell struct { DecidedBy string `json:"decided_by"` RuleID int64 `json:"rule_id,omitempty"` // the deciding rule Exceptions []MatrixException `json:"exceptions"` + // Summary is the pair at a glance: allowed or blocked when every new + // connection gets the same, partly when some are let through and + // some not. + Summary string `json:"summary"` + // Allowed and Blocked count the exceptions that let part of the + // traffic through, and that block part of it. + Allowed int `json:"allowed"` + Blocked int `json:"blocked"` + // Open sums up what the allowing exceptions let through, shortest + // first in their order: "ssh", "dns", "5 port forwards". + Open []string `json:"open"` + // SameZone: from a zone to itself. Note says what of that traffic + // passes the firewall at all. + SameZone bool `json:"same_zone,omitempty"` + Note string `json:"note,omitempty"` + // Hits are the deciding rule's (or policy's) packets, all of its + // traffic since housecarld started (nil: no counter). + Hits *uint64 `json:"hits,omitempty"` + // Marks are the counters behind Hits. + Marks []string `json:"-"` } // MatrixException is a rule (or built-in) that decides for part of a @@ -480,4 +500,12 @@ type MatrixException struct { Verdict string `json:"verdict"` // What it covers: "from trusted · ssh", "Monday 08:00–17:00". What string `json:"what"` + // Short is What in a word or two, for the overview: its services, + // or else its first condition. + Short string `json:"short"` + // Hits are its packets, all of its traffic since housecarld started + // (a rule may apply to several pairs; nil: no counter). + Hits *uint64 `json:"hits,omitempty"` + // Marks are the counters behind Hits ("r12", "b-ping", "f3"). + Marks []string `json:"-"` } diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index 0e7a47a..66aeaa2 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -63,6 +63,50 @@ func Cell(st *model.State, o Options, from, to string) (api.MatrixCell, bool) { } 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) @@ -71,7 +115,7 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell { 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)"}) + 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 @@ -81,22 +125,26 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell { 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"}) + 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)"}) + 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"}) + 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 n := forwards(st); n > 0 && external && !st.Zone(to).External { + 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: fmt.Sprintf("%d port forward%s to inside hosts", n, map[bool]string{true: "", false: "s"}[n == 1])}) + 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"}) + 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) { @@ -104,17 +152,22 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell { } 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 } -func forwards(st *model.State) int { - n := 0 +// 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 != "" { - n++ + marks = append(marks, fmt.Sprintf("f%d", f.ID)) } } - return n + return marks } // walk goes through a chain's rules for the pair, as nftables checks @@ -130,8 +183,10 @@ 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}) + 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}) continue } if !zones(r, from, to) || !forNew(r) { @@ -151,10 +206,11 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string) if what == "" { what = "everything" } - c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: v, What: what}) + 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 = v, "rule "+r.Label(), r.ID + c.Verdict, c.DecidedBy, c.RuleID, c.Marks = v, "rule "+r.Label(), r.ID, mark return true } return false @@ -265,6 +321,38 @@ func forNew(r *model.Rule) bool { return false } +// 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 { diff --git a/internal/matrix/matrix_test.go b/internal/matrix/matrix_test.go index 76edfa5..ae6dad2 100644 --- a/internal/matrix/matrix_test.go +++ b/internal/matrix/matrix_test.go @@ -110,3 +110,51 @@ func TestConnected(t *testing.T) { t.Errorf("guest → lan: %v", ids(guestLan)) // not "to a guest": it is in guest, not lan } } + +// TestSummary: each pair at a glance, what its exceptions let through, +// and the counters behind each line. +func TestSummary(t *testing.T) { + st := state() + st.Settings.InputPolicy = model.PolicyDrop + m := Build(st) + for _, c := range []struct { + from, to, summary string + allowed, blocked int + open string + }{ + {"lan", "wan", "allowed", 0, 0, ""}, + {"guest", "wan", "partly", 1, 0, "http, https"}, + {"wan", "lan", "partly", 1, 1, "1 port forward"}, + {"lan", "guest", "blocked", 0, 0, ""}, + {"lan", Firewall, "partly", 4, 0, "ICMPv6|ping|ssh from admins|dns"}, + } { + got := find(m, c.from, c.to) + if got.Summary != c.summary || got.Allowed != c.allowed || got.Blocked != c.blocked || strings.Join(got.Open, "|") != c.open { + t.Errorf("%s → %s: %s +%d -%d %q; want %s +%d -%d %q", c.from, c.to, got.Summary, got.Allowed, got.Blocked, got.Open, c.summary, c.allowed, c.blocked, c.open) + } + } + if c := find(m, "lan", "lan"); !c.SameZone || !strings.Contains(c.Note, "out of eth1") { + t.Errorf("same zone: %+v", c) + } + if c := find(m, "lan", "wan"); c.SameZone || c.Note != "" { + t.Errorf("not the same zone: %+v", c) + } + wl := find(m, "wan", "lan") + if strings.Join(wl.Exceptions[0].Marks, ",") != "b-antispoof,b-antispoof6" || strings.Join(wl.Exceptions[1].Marks, ",") != "f30" || strings.Join(wl.Marks, ",") != "r15" { + t.Errorf("marks: %+v", wl) + } + icmp := &model.Rule{Match: model.Match{Services: []string{"icmp/echo-request", "icmp/echo-reply", "icmpv6/echo-request", "ssh"}}} + if s := short(icmp, "x"); s != "icmp, icmpv6, ssh" { + t.Errorf("short: %q", s) + } + ssh := &model.Rule{Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Days: []string{"mon"}} + if s := short(ssh, conditions(st, ssh)); s != "ssh from trusted" { + t.Errorf("short with a source: %q", s) + } + if s := short(&model.Rule{Days: []string{"mon"}}, "mon"); s != "mon" { + t.Errorf("short of a time: %q", s) + } + if c := find(m, "lan", Firewall); strings.Join(c.Marks, ",") != "p-input" { + t.Errorf("a drop policy's mark: %v", c.Marks) + } +} diff --git a/internal/server/h_config.go b/internal/server/h_config.go index 175885c..1abfcb5 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -15,6 +15,7 @@ import ( "housecarl/internal/engine" "housecarl/internal/matrix" "housecarl/internal/model" + "housecarl/internal/nft" "housecarl/internal/store" ) @@ -471,10 +472,39 @@ func (s *Server) zoneMatrix(w http.ResponseWriter, r *http.Request, p *auth.Prin if err != nil { return err } - writeJSON(w, http.StatusOK, matrix.Build(st, matrix.Options{Connected: s.connected(st)})) + m := matrix.Build(st, matrix.Options{Connected: s.connected(st)}) + if t, err := s.fw.Table(r.Context()); err == nil { + matrixHits(&m, t.Counters) + } + writeJSON(w, http.StatusOK, m) return nil } +// matrixHits fills in the hits of the matrix's rules, built-ins and +// policies from the counters. +func matrixHits(m *api.Matrix, counters map[string]nft.Counter) { + hits := func(marks []string) *uint64 { + var n uint64 + found := false + for _, mk := range marks { + if c, ok := counters[mk]; ok { + n, found = n+c.Packets, true + } + } + if !found { + return nil + } + return &n + } + for i := range m.Cells { + c := &m.Cells[i] + c.Hits = hits(c.Marks) + for j := range c.Exceptions { + c.Exceptions[j].Hits = hits(c.Exceptions[j].Marks) + } + } +} + // connected maps the networks on the firewall's interfaces (link-local // and loopback aside) to their zones. func (s *Server) connected(st *model.State) map[netip.Prefix]string { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7adf499..669492f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -891,6 +891,13 @@ func TestMatrixEndpoint(t *testing.T) { if c := cell("lan", "wan"); c.Verdict != "allowed" { t.Errorf("lan → wan with the changeset: %+v", c) } + // Hits: summed over an object's counters; none without a counter. + hm := api.Matrix{Cells: []api.MatrixCell{{Marks: []string{"p-forward"}, Exceptions: []api.MatrixException{ + {Marks: []string{"b-antispoof", "b-antispoof6"}}, {Marks: []string{"r9"}}}}}} + matrixHits(&hm, map[string]nft.Counter{"p-forward": {Packets: 4}, "b-antispoof": {Packets: 2}, "b-antispoof6": {Packets: 3}}) + if c := hm.Cells[0]; c.Hits == nil || *c.Hits != 4 || c.Exceptions[0].Hits == nil || *c.Exceptions[0].Hits != 5 || c.Exceptions[1].Hits != nil { + t.Errorf("hits: %+v", c) + } } // TestHostProblems: an enabled nftables.service, and an imported table diff --git a/internal/web/static/assets/pages/matrix.js b/internal/web/static/assets/pages/matrix.js index d74b491..6094532 100644 --- a/internal/web/static/assets/pages/matrix.js +++ b/internal/web/static/assets/pages/matrix.js @@ -11,7 +11,7 @@ export async function render(el) { const name = (z) => z === "firewall" ? h("em", null, "firewall") : z; mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Zone matrix"), - h("div", { class: "muted" }, "What happens to a new connection from a zone (rows) to a zone (columns), or to and from the firewall itself, worked out from the rules in order: allowed or blocked, and how many rules decide for part of the traffic first (some services, addresses, times). Click a cell for the rules."))), + h("div", { class: "muted" }, "What happens to a new connection from a zone (rows) to a zone (columns), or to and from the firewall itself, worked out from the rules in order: all allowed, all blocked, or partly (some services, addresses or times let through, the rest not). Click a cell for the rules."))), pending ? h("div", { class: "msg info" }, "Shown with your pending changes.") : null, !m.zones.length ? h("div", { class: "panel" }, h("div", { class: "empty" }, "No zones yet: add them under Objects → Zones.")) : h("div", { class: "panel" }, h("div", { class: "table-wrap" }, h("table", { class: "matrix" }, @@ -19,20 +19,32 @@ export async function render(el) { h("tbody", null, cols.map((from) => h("tr", null, h("th", null, name(from)), cols.map((to) => { const c = cells.get(from + "\u0000" + to); if (!c) return h("td", { class: "matrix-none" }, "–"); - const n = c.exceptions.length; - return h("td", { class: "matrix-" + c.verdict + (n ? " matrix-some" : ""), title: c.verdict + " by " + c.decided_by + (n ? ", " + n + " exception" + (n === 1 ? "" : "s") + " first" : "") }, - h("button", { type: "button", class: "matrix-cell", onclick: () => detail(c) }, c.verdict, n ? h("span", { class: "matrix-n" }, "+" + n) : null)); + const open = c.open || []; + const counts = [c.allowed ? "+" + c.allowed : "", c.blocked ? "−" + c.blocked : ""].filter(Boolean).join(" "); + const title = (c.same_zone ? c.note + ". " : "") + "The rest: " + c.verdict + " by " + c.decided_by + + (c.allowed ? "; " + c.allowed + " rule(s) let part of it through" : "") + (c.blocked ? "; " + c.blocked + " block part of it" : ""); + return h("td", { class: "matrix-" + c.summary + (c.same_zone ? " matrix-same" : ""), title }, + h("button", { type: "button", class: "matrix-cell", onclick: () => detail(c) }, + c.summary === "partly" ? "partly" : c.verdict, counts ? h("span", { class: "matrix-n" }, counts) : null, + open.length && !c.same_zone ? h("span", { class: "matrix-open" }, open.slice(0, 3).join(" · ") + (open.length > 3 ? " · +" + (open.length - 3) + " more" : "")) : null)); })))))), - h("p", { class: "muted small" }, "Zone to zone is the forward chain; to the firewall, input; from it, output. Exceptions are rules (and built-ins such as ping or port forwards) that decide for some of the traffic before the rest falls to the deciding rule or the policy. Replies and established connections aren't shown: the built-in lets those through."))); + h("p", { class: "muted small" }, "Zone to zone is the forward chain; to the firewall, input; from it, output. +N: rules (and built-ins such as ping or port forwards) that let part of the traffic through before the rest falls to the deciding rule or the policy; −N: those that block part of it. Under each cell, what is let through. A zone to itself (faded): only traffic routed between its interfaces passes the firewall. Replies and established connections aren't shown: the built-in lets those through."))); } function detail(c) { const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label; const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]); + const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString()); + const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary]; modal(c.from + " → " + c.to, h("div", null, - h("p", { class: "muted" }, "New connections, checked in this order (the " + c.chain + " chain):"), - h("div", { class: "table-wrap" }, h("table", null, h("tbody", null, - c.exceptions.map((e) => h("tr", null, h("td", null, v(e.verdict)), h("td", null, e.what), h("td", { class: "small" }, ruleLink(e.rule_id, e.chain, e.label)))), - h("tr", { class: "matrix-rest" }, h("td", null, v(c.verdict)), h("td", null, h("strong", null, c.exceptions.length ? "the rest" : "everything")), - h("td", { class: "small" }, ruleLink(c.rule_id, c.chain, c.decided_by))))))), [{ label: "Close", value: null }], { wide: true }); + h("p", null, h("strong", null, summary), h("span", { class: "muted" }, " · new connections, checked in this order (the " + c.chain + " chain):")), + c.note ? h("div", { class: "msg info" }, c.note[0].toUpperCase() + c.note.slice(1) + ".") : null, + h("div", { class: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, ""), h("th", null, "What"), h("th", null, "By"), h("th", { class: "num" }, "Hits"))), + h("tbody", null, + c.exceptions.map((e) => h("tr", null, h("td", null, v(e.verdict)), h("td", null, e.what), h("td", { class: "small" }, ruleLink(e.rule_id, e.chain, e.label)), hits(e.hits))), + h("tr", { class: "matrix-rest" }, h("td", null, v(c.verdict)), h("td", null, h("strong", null, c.exceptions.length ? "the rest" : "everything")), + h("td", { class: "small" }, ruleLink(c.rule_id, c.chain, c.decided_by)), hits(c.hits))))), + h("p", { class: "muted small" }, "Hits: each rule's packets since housecarld started, in all its traffic: a rule can cover several pairs.")), + [{ label: "Close", value: null }], { wide: true }); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index c50272c..4c58de0 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -425,10 +425,15 @@ table.matrix th, table.matrix td { text-align: center; } table.matrix tbody th { text-align: left; } table.matrix th.corner { font-weight: 400; color: var(--muted); text-transform: none; } table.matrix td { padding: 4px; } -.matrix-cell { width: 100%; min-width: 7em; border: 0; background: transparent; color: inherit; font-weight: 600; padding: 6px 8px; cursor: pointer; } +table.matrix { table-layout: fixed; width: 100%; min-width: 640px; } +table.matrix thead th:first-child, table.matrix tbody th { width: 8em; } +.matrix-cell { display: block; width: 100%; min-width: 7em; border: 0; background: transparent; color: inherit; font-weight: 600; padding: 6px 8px; cursor: pointer; white-space: normal; text-align: center; } td.matrix-allowed { background: var(--add); color: var(--add-text); } td.matrix-blocked { background: var(--del); color: var(--del-text); } -.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; color: var(--warn); } +.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; } +td.matrix-partly { background: var(--warn-soft); color: var(--text); } +td.matrix-same { opacity: 0.55; } +.matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; } td.matrix-none { color: var(--muted); } tr.matrix-rest td { border-top: 2px solid var(--border); } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index a209f55..6dad8bf 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -334,9 +334,12 @@ run "the quota rule goes" 0 H rule delete -y "$QR" run "the matrix" 0 H matrix contains "^firewall *allow" +contains "^dhcp *partly +[0-9]" +contains "dhcp → wan *.*from trusted_ips" run "LAN to the internet" 0 H matrix dhcp wan -contains "allowed *from trusted_ips *rule forward #[0-9]* (Forward from trusted IPs)" -contains "blocked *the rest *forward policy" +contains "new connections: partly allowed" +contains "allowed *from trusted_ips *rule forward #[0-9]* (Forward from trusted IPs) *[0-9]" +contains "blocked *the rest *forward policy *[0-9]" not_contains "wg0_net" run "the internet to the firewall" 0 H matrix wan firewall contains "blocked *sources that can't come from outside"