Compare commits

...
Author SHA1 Message Date
abrown38andClaude Opus 5.5 40748d3042 Release 0.1.1-rc.4
release / release (push) Successful in 1m3s
Housecarl 0.1.1's fourth release candidate. Since rc.3: the matrix reads
raw rules that are a match and a verdict (the imported neighbour
discovery rule is no longer unknown), and links to a rule open it on the
Rules page, highlighted, with rule IDs shown. make test and make e2e
(286 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:33 +01:00
abrown38andClaude Opus 5.5 21ddced1ca Web UI: links to a rule open it, highlighted; rule IDs shown
A rule named in the matrix, the log, the tracer, statistics or the
dashboard opens the Rules page on its chain, scrolled to it and
highlighted (#/rules?rule=ID; the chain is looked up when the link has
none). The Rules page shows each rule's ID beside its order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:33 +01:00
abrown38andClaude Opus 5.5 566c07cbc7 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>
2026-10-20 19:18:33 +01:00
abrown38andClaude Opus 5.5 fd2fc3b6f0 Release 0.1.1-rc.3
release / release (push) Successful in 1m4s
Housecarl 0.1.1's third release candidate. Since rc.2: a clearer zone
matrix (allow, block or partly, what is let through, hits per rule, hc
matrix aligned in colour), and counters that forget objects no longer
there (after an import with --replace). make test and make e2e (285
checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:01:19 +01:00
abrown38andClaude Opus 5.5 5ee324c588 Zone matrix: partly, what is let through, hits; hc matrix aligned
A cell reads allow, block or partly (some new connections let through,
the rest not), with its exceptions counted by what they do (+3 -1), and
what they let through: services with the addresses they are limited to,
under the grid in hc matrix and 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. hc matrix pads the
grid by visible width: the colours' escape codes had thrown it out of
line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:00:33 +01:00
abrown38andClaude Opus 5.5 429c910df1 Counters: forget the totals of objects that no longer exist
hc counters kept listing the objects of a configuration replaced since
housecarld started (an import with --replace gives every object a new
ID) beside the current ones. Disabled objects, built-ins and policies
keep their totals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:45:51 +01:00
24 changed files with 699 additions and 76 deletions

No files matched your search

+22
View File
@@ -8,6 +8,22 @@ All notable changes to Housecarl are documented here. The format follows
### Added ### Added
- **Links to a rule open it.** A rule named in the matrix, the log, the
tracer, statistics or the dashboard's busiest rules opens the Rules
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
The Rules page shows each rule's ID (`#410`, as `hc` and labels name
it) beside its order.
- **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.
Raw rules that are a match and then accept, drop or reject (without
interfaces) are read instead of shown as unknown.
- **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md): - **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
Housecarl's ruleset is loaded beside the firewall you have but blocks, Housecarl's ruleset is loaded beside the firewall you have but blocks,
rejects and translates nothing. Drops and rejects are counted and accept rejects and translates nothing. Drops and rejects are counted and accept
@@ -42,6 +58,12 @@ All notable changes to Housecarl are documented here. The format follows
### Fixed ### 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
totals of objects that no longer exist are now forgotten.
- Import: two base chains on one hook had their rules imported twice, - Import: two base chains on one hook had their rules imported twice,
and the second chain's leading built-ins were moved above the first and the second chain's leading built-ins were moved above the first
chain's rules; they now follow each other by priority (read in a chain's rules; they now follow each other by priority (read in a
+1 -1
View File
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.1.1 is in testing (0.1.1-rc.2): zones, address sets and Housecarl 0.1.1 is in testing (0.1.1-rc.4): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset network changes (confirmed by the client that made them), the ruleset
+1 -1
View File
@@ -1 +1 @@
0.1.1-rc.2 0.1.1-rc.4
+26
View File
@@ -1,8 +1,11 @@
package main package main
import ( import (
"regexp"
"strings"
"testing" "testing"
"housecarl/internal/matrix"
"housecarl/internal/model" "housecarl/internal/model"
) )
@@ -40,3 +43,26 @@ func TestLimitAndDescribe(t *testing.T) {
t.Errorf("editList: %v", got) 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())
}
}
+94 -23
View File
@@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/spf13/cobra" "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) { 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") cols := append(append([]string{}, m.Zones...), "firewall")
cells := map[[2]string]api.MatrixCell{} cells := map[[2]string]api.MatrixCell{}
for _, c := range m.Cells { for _, c := range m.Cells {
cells[[2]string{c.From, c.To}] = c cells[[2]string{c.From, c.To}] = c
} }
color := isTerminal(os.Stdout) && flagOutput == "table" // The grid is padded by hand: tabwriter would count the colours'
fmt.Fprint(w, "FROM \\ TO") // escape codes as text, and misalign the columns.
for _, c := range cols { grid := [][]string{append([]string{"FROM \\ TO"}, cols...)}
fmt.Fprintf(w, "\t%s", c) kinds := [][]string{make([]string, len(cols)+1)}
} same := false
fmt.Fprintln(w)
for _, from := range cols { for _, from := range cols {
fmt.Fprint(w, from) row, kind := []string{from}, []string{""}
for _, to := range cols { for _, to := range cols {
c, ok := cells[[2]string{from, to}] c, ok := cells[[2]string{from, to}]
text := "-" if !ok {
if ok { row, kind = append(row, "-"), append(kind, "")
text = map[string]string{"allowed": "allow", "blocked": "block"}[c.Verdict] continue
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"
}
} }
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) { 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) summary := map[string]string{"allowed": "all allowed", "blocked": "all blocked", "partly": "partly allowed"}[c.Summary]
for _, e := range c.Exceptions { fmt.Fprintf(w, "%s → %s (the %s chain), new connections: %s\n", c.From, c.To, c.Chain, summary)
fmt.Fprintf(w, " %s\t%s\t%s\n", e.Verdict, e.What, e.Label) 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).")
} }
+1 -1
View File
@@ -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 /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 /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 /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 /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 /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` | | `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
+1 -1
View File
@@ -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)) | | `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 | | `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)) | | `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)) | | `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
### zone, set, service ### zone, set, service
+3 -1
View File
@@ -154,7 +154,9 @@ table on its hook, so a drop in any of them wins.
Every rule, NAT rule, built-in and policy has a counter. `hc rule list`, Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
`hc forward list` and the web UI show packets; `hc counters` shows them `hc forward list` and the web UI show packets; `hc counters` shows them
all, with bytes. They count from housecarld's start, and carry across all, with bytes. They count from housecarld's start, and carry across
changes to the ruleset. changes to the ruleset. A deleted object's counter goes with it; an
import with `--replace` gives every object a new ID, so its counters
start again. A disabled rule keeps its count for when it is enabled.
## Quotas ## Quotas
+47 -11
View File
@@ -278,31 +278,67 @@ chain; from it: output; zone to zone: forward).
``` ```
$ hc matrix $ hc matrix
FROM \ TO guest lan wan firewall FROM \ TO guest lan wan firewall
guest block block block +1 block +3 guest block * block partly +1 partly +4
lan block block allow block +4 lan block block * allow partly +4
wan block +2 block +2 block +1 block +3 wan partly +1 -1 partly +1 -1 block -1 * partly +3 -1
firewall allow allow allow - 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 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, built-ins that act on new connections (ping from internal zones,
ICMPv6, the DHCPv6 client, antispoofing, port forwards): 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, * 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. * 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 `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: guest → wan (the forward chain), new connections: partly allowed
allowed http, https rule forward #14 VERDICT WHAT BY HITS
blocked the rest forward policy 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 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
+28
View File
@@ -467,6 +467,26 @@ type MatrixCell struct {
DecidedBy string `json:"decided_by"` DecidedBy string `json:"decided_by"`
RuleID int64 `json:"rule_id,omitempty"` // the deciding rule RuleID int64 `json:"rule_id,omitempty"` // the deciding rule
Exceptions []MatrixException `json:"exceptions"` 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 // MatrixException is a rule (or built-in) that decides for part of a
@@ -480,4 +500,12 @@ type MatrixException struct {
Verdict string `json:"verdict"` Verdict string `json:"verdict"`
// What it covers: "from trusted · ssh", "Monday 08:00–17:00". // What it covers: "from trusted · ssh", "Monday 08:00–17:00".
What string `json:"what"` 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:"-"`
} }
+237 -14
View File
@@ -9,6 +9,7 @@ package matrix
import ( import (
"fmt" "fmt"
"net/netip" "net/netip"
"regexp"
"strings" "strings"
"housecarl/internal/api" "housecarl/internal/api"
@@ -63,6 +64,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 { 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{}} c := api.MatrixCell{From: from, To: to, Chain: chain, Exceptions: []api.MatrixException{}}
b := st.Settings.Builtins b := st.Settings.Builtins
fromZone := st.Zone(from) fromZone := st.Zone(from)
@@ -71,7 +116,7 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
if chain != model.ChainOutput { if chain != model.ChainOutput {
if b.Antispoof && external { if b.Antispoof && external {
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: model.ChainEarly, Label: "built-in: antispoof", Verdict: "blocked", 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 { if decided := walk(st, o, &c, model.ChainEarly, from, ""); decided {
return c return c
@@ -81,22 +126,26 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
switch chain { switch chain {
case model.ChainInput: case model.ChainInput:
if b.ICMPv6 { 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 { 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 { 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: 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", 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: case model.ChainOutput:
if st.Settings.OutputPolicy == model.PolicyDrop && b.ICMPv6 { 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) { if walk(st, o, &c, chain, from, to) {
@@ -104,17 +153,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] 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" 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 return c
} }
func forwards(st *model.State) int { // forwards returns the counters' marks of the port forwards that send
n := 0 // connections inside.
func forwards(st *model.State) []string {
var marks []string
for _, f := range st.Forwards { for _, f := range st.Forwards {
if !f.Disabled && f.To != "" { 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 // walk goes through a chain's rules for the pair, as nftables checks
@@ -130,8 +184,22 @@ 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)}
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)
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) {
@@ -151,10 +219,11 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string)
if what == "" { if what == "" {
what = "everything" 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 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 true
} }
return false return false
@@ -265,6 +334,160 @@ 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
// 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 // conditions says what limits a rule to part of a pair's traffic ("" for
// none: it decides the pair). // none: it decides the pair).
func conditions(st *model.State, r *model.Rule) string { func conditions(st *model.State, r *model.Rule) string {
+87
View File
@@ -110,3 +110,90 @@ func TestConnected(t *testing.T) {
t.Errorf("guest → lan: %v", ids(guestLan)) // not "to a guest": it is in guest, not lan 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)
}
}
// 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)
}
}
+30
View File
@@ -556,9 +556,39 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
f.base[k] = b f.base[k] = b
} }
} }
pruneCounters(f.base, st)
return nil return nil
} }
// pruneCounters forgets the totals of objects st no longer has (deleted,
// or replaced by an import with new IDs), so they aren't listed beside
// the current ones. A disabled object keeps its totals for when it comes
// back; built-ins and policies are always kept.
func pruneCounters(base map[string]Counter, st *model.State) {
ids := map[string]bool{}
for _, r := range st.Rules {
ids[fmt.Sprintf("r%d", r.ID)] = true
}
for _, x := range st.Forwards {
ids[fmt.Sprintf("f%d", x.ID)] = true
}
for _, x := range st.SNATs {
ids[fmt.Sprintf("s%d", x.ID)] = true
}
for _, x := range st.Redirects {
ids[fmt.Sprintf("d%d", x.ID)] = true
}
for mark := range base {
obj, _, _ := strings.Cut(mark, "-") // f3-hairpin, f3-6 are f3's
if len(obj) < 2 || !strings.ContainsRune("rfsd", rune(obj[0])) || strings.Trim(obj[1:], "0123456789") != "" {
continue // b-established, p-input
}
if !ids[obj] {
delete(base, mark)
}
}
}
// SaveBoot writes the ruleset of st for the boot unit, and the fallback // SaveBoot writes the ruleset of st for the boot unit, and the fallback
// it loads if that fails (FallbackFile), atomically. The boot ruleset has // it loads if that fails (FallbackFile), atomically. The boot ruleset has
// no flowtable: it loads before the network comes up, when its devices // no flowtable: it loads before the network comes up, when its devices
+24
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"testing" "testing"
@@ -437,3 +438,26 @@ func TestShadowInContainer(t *testing.T) {
t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err) t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err)
} }
} }
// TestPruneCounters: the totals of objects gone (an import with --replace
// gives every object a new ID) are forgotten; built-ins, policies and
// disabled objects keep theirs.
func TestPruneCounters(t *testing.T) {
base := map[string]Counter{}
for _, m := range []string{"r1", "r2", "r3", "f4", "f4-hairpin", "f4-6", "f5-hairpin", "s6", "d7", "d8", "b-established", "p-input", "fallback-input"} {
base[m] = Counter{Packets: 1}
}
st := model.NewState()
st.Rules = []*model.Rule{{ID: 1}, {ID: 3, Disabled: true}}
st.Forwards = []*model.PortForward{{ID: 4}}
st.Redirects = []*model.Redirect{{ID: 8}}
pruneCounters(base, st)
var got []string
for m := range base {
got = append(got, m)
}
sort.Strings(got)
if strings.Join(got, " ") != "b-established d8 f4 f4-6 f4-hairpin fallback-input p-input r1 r3" {
t.Errorf("kept %v", got)
}
}
+31 -1
View File
@@ -15,6 +15,7 @@ import (
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/matrix" "housecarl/internal/matrix"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/nft"
"housecarl/internal/store" "housecarl/internal/store"
) )
@@ -471,10 +472,39 @@ func (s *Server) zoneMatrix(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err != nil { if err != nil {
return err 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 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 // connected maps the networks on the firewall's interfaces (link-local
// and loopback aside) to their zones. // and loopback aside) to their zones.
func (s *Server) connected(st *model.State) map[netip.Prefix]string { func (s *Server) connected(st *model.State) map[netip.Prefix]string {
+7
View File
@@ -891,6 +891,13 @@ func TestMatrixEndpoint(t *testing.T) {
if c := cell("lan", "wan"); c.Verdict != "allowed" { if c := cell("lan", "wan"); c.Verdict != "allowed" {
t.Errorf("lan → wan with the changeset: %+v", c) 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 // TestHostProblems: an enabled nftables.service, and an imported table
@@ -49,7 +49,7 @@ export async function render(el) {
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")), h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
table([ table([
{ label: "Chain", render: (r) => r.chain }, { label: "Chain", render: (r) => r.chain },
{ label: "Rule", render: (r) => r.raw || describe(r) }, { label: "Rule", render: (r) => h("a", { href: "#/rules?chain=" + r.chain + "&rule=" + r.id }, "#" + r.id + " ", r.raw || describe(r)) },
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) }, { label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) }, { label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment }, { label: "Comment", render: (r) => r.comment },
+1 -1
View File
@@ -16,7 +16,7 @@ export async function render(el, params, query) {
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))), h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
h("tbody", null, es.map((e) => h("tr", null, h("tbody", null, es.map((e) => h("tr", null,
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })), h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix, h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules?rule=" + e.mark.slice(1), title: "rule #" + e.mark.slice(1) }, e.prefix) : e.prefix,
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null), e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""), h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)), h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
+23 -11
View File
@@ -11,7 +11,7 @@ export async function render(el) {
const name = (z) => z === "firewall" ? h("em", null, "firewall") : z; const name = (z) => z === "firewall" ? h("em", null, "firewall") : z;
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Zone matrix"), 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, 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.")) : !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" }, 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) => { h("tbody", null, cols.map((from) => h("tr", null, h("th", null, name(from)), cols.map((to) => {
const c = cells.get(from + "\u0000" + to); const c = cells.get(from + "\u0000" + to);
if (!c) return h("td", { class: "matrix-none" }, "–"); if (!c) return h("td", { class: "matrix-none" }, "–");
const n = c.exceptions.length; const open = c.open || [];
return h("td", { class: "matrix-" + c.verdict + (n ? " matrix-some" : ""), title: c.verdict + " by " + c.decided_by + (n ? ", " + n + " exception" + (n === 1 ? "" : "s") + " first" : "") }, const counts = [c.allowed ? "+" + c.allowed : "", c.blocked ? "−" + c.blocked : ""].filter(Boolean).join(" ");
h("button", { type: "button", class: "matrix-cell", onclick: () => detail(c) }, c.verdict, n ? h("span", { class: "matrix-n" }, "+" + n) : null)); 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) { 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 ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain + "&rule=" + id, 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 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, modal(c.from + " → " + c.to, h("div", null,
h("p", { class: "muted" }, "New connections, checked in this order (the " + c.chain + " chain):"), h("p", null, h("strong", null, summary), h("span", { class: "muted" }, " · new connections, checked in this order (the " + c.chain + " chain):")),
h("div", { class: "table-wrap" }, h("table", null, h("tbody", null, c.note ? h("div", { class: "msg info" }, c.note[0].toUpperCase() + c.note.slice(1) + ".") : 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("div", { class: "table-wrap" }, h("table", null,
h("tr", { class: "matrix-rest" }, h("td", null, v(c.verdict)), h("td", null, h("strong", null, c.exceptions.length ? "the rest" : "everything")), h("thead", null, h("tr", null, h("th", null, ""), h("th", null, "What"), h("th", null, "By"), h("th", { class: "num" }, "Hits"))),
h("td", { class: "small" }, ruleLink(c.rule_id, c.chain, c.decided_by))))))), [{ label: "Close", value: null }], { wide: true }); 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 });
} }
+16 -3
View File
@@ -24,7 +24,14 @@ const builtins = {
}; };
export async function render(el, params, query) { export async function render(el, params, query) {
const chain = query.get("chain") || "input"; // ?rule=ID (from the matrix, the log, the tracer, statistics): show
// that rule, in its chain, highlighted.
const target = Number(query.get("rule")) || 0;
let chain = query.get("chain");
if (!chain && target) {
try { chain = (await get("/rules/" + target)).chain; } catch { /* gone: the default chain */ }
}
chain = chain || "input";
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]); const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
// As nftables checks them: the rules that count traffic first. // As nftables checks them: the rules that count traffic first.
const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))]; const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))];
@@ -40,7 +47,8 @@ export async function render(el, params, query) {
h("div", { class: "panel" }, h("div", { class: "panel" },
builtinsRow(chain, settings, cs), builtinsRow(chain, settings, cs),
table([ table([
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) }, { label: "Order", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
{ label: "ID", render: (r) => r.id ? h("span", { class: "muted mono small" }, "#" + r.id) : "" },
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)] { label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] }, : [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
{ label: "Action", render: actionBadge }, { label: "Action", render: actionBadge },
@@ -59,10 +67,15 @@ export async function render(el, params, query) {
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"), h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
]; ];
} } : null, } } : null,
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }), ].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => rowClass(r) + (target && r.id === target ? " row-target" : "") }),
rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null, rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null,
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"), h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null))); policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
if (target) {
const row = el.querySelector("tr.row-target");
if (row) requestAnimationFrame(() => row.scrollIntoView({ block: "center" }));
else el.querySelector(".tabs").after(h("div", { class: "msg warn" }, "There is no rule #" + target + " (any more)."));
}
} }
// everyPacket: checked before established connections are accepted (as // everyPacket: checked before established connections are accepted (as
+1 -1
View File
@@ -180,7 +180,7 @@ function rangeRow(since) {
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the // ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
// built-ins under Settings). // built-ins under Settings).
function ruleHref(mark) { function ruleHref(mark) {
if (/^r\d/.test(mark)) return "#/rules"; if (/^r\d+$/.test(mark)) return "#/rules?rule=" + mark.slice(1);
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings"; if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
if (mark.startsWith("f")) return "#/nat"; if (mark.startsWith("f")) return "#/nat";
return null; return null;
+1 -1
View File
@@ -60,7 +60,7 @@ export async function render(el, params, query) {
h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null, h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null,
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" }, h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" },
h("td", { class: "muted small" }, s.table + " " + s.chain), h("td", { class: "muted small" }, s.table + " " + s.chain),
h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain }, s.label) : s.label) h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain + "&rule=" + s.mark.slice(1).split("-")[0] }, s.label) : s.label)
: s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)), : s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
h("td", { class: "small" }, s.verdict)))))); h("td", { class: "small" }, s.verdict))))));
} }
+10 -2
View File
@@ -425,10 +425,18 @@ table.matrix th, table.matrix td { text-align: center; }
table.matrix tbody th { text-align: left; } table.matrix tbody th { text-align: left; }
table.matrix th.corner { font-weight: 400; color: var(--muted); text-transform: none; } table.matrix th.corner { font-weight: 400; color: var(--muted); text-transform: none; }
table.matrix td { padding: 4px; } 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-allowed { background: var(--add); color: var(--add-text); }
td.matrix-blocked { background: var(--del); color: var(--del-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; }
/* The rule a link pointed at (?rule=ID). */
tr.row-target td { background: var(--accent-soft); font-weight: 600; }
tr.row-target td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
.matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; }
td.matrix-none { color: var(--muted); } td.matrix-none { color: var(--muted); }
tr.matrix-rest td { border-top: 2px solid var(--border); } tr.matrix-rest td { border-top: 2px solid var(--border); }
+6 -2
View File
@@ -334,13 +334,17 @@ run "the quota rule goes" 0 H rule delete -y "$QR"
run "the matrix" 0 H matrix run "the matrix" 0 H matrix
contains "^firewall *allow" contains "^firewall *allow"
contains "^dhcp *partly +[0-9]"
contains "dhcp → wan *.*from trusted_ips"
run "LAN to the internet" 0 H matrix dhcp wan run "LAN to the internet" 0 H matrix dhcp wan
contains "allowed *from trusted_ips *rule forward #[0-9]* (Forward from trusted IPs)" contains "new connections: partly allowed"
contains "blocked *the rest *forward policy" 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" 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)