diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c861b..6eaf852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ the way Jarl and Steward are. - **Warnings about the machine:** while `nftables.service` is enabled (it loads `/etc/nftables.conf` at boot, and flushes every table when stopped), and while the table an import came from still applies. +- **The zone matrix** (Matrix, `hc matrix`): what the rules do to new + connections between zones and to and from the firewall, worked out + from the rules in order, with each pair's exceptions; rules about + networks connected to other zones are left out of a pair. - **Connections and tracing:** the connections the kernel tracks, with their NAT, and closing them so the rules check them again (Traffic → Connections, `hc connections`); a rule option, "every packet", for diff --git a/README.md b/README.md index 91192ae..d83d1cd 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ table of its own. ruleset into zones, sets, services, rules and port forwards, keeping the rules' order, and keeps what it can't translate as raw rules ([docs/import.md](docs/import.md)). -* **Easy to watch.** Hits per rule, a live log of what was dropped and +* **Easy to watch.** A zone matrix of what is allowed between zones, + hits per rule, a live log of what was dropped and why, the connections the firewall tracks (and closing them), a packet tracer that names the rule that dropped something, in any table, and the history of every change, field by field, with undo. diff --git a/cmd/hc/main.go b/cmd/hc/main.go index 12e6049..218364b 100644 --- a/cmd/hc/main.go +++ b/cmd/hc/main.go @@ -191,7 +191,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, } } add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(), - rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), interfaceCmd()) + rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), matrixCmd(), interfaceCmd()) add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd()) add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd()) return root diff --git a/cmd/hc/traffic.go b/cmd/hc/traffic.go index a191d92..01edf7f 100644 --- a/cmd/hc/traffic.go +++ b/cmd/hc/traffic.go @@ -206,3 +206,78 @@ func printPacket(w io.Writer, p api.TracePacket) { fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided) } } + +func matrixCmd() *cobra.Command { + return &cobra.Command{ + Use: "matrix [FROM TO]", Short: "What the rules do to new connections between zones", + Long: `Shows, for each pair of zones (and the firewall itself), what happens to a +new connection: allowed or blocked, decided by a rule or the policy, and +how many rules decide for part of the traffic first (+N: exceptions, +such as some services or addresses). With FROM and TO (a zone, or +"firewall"), that pair's exceptions in order, and what decides the rest. + + hc matrix + hc matrix guest wan + hc matrix lan firewall`, + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + return fmt.Errorf("give both: FROM and TO") + } + m, err := get[api.Matrix]("/matrix", nil) + if err != nil { + return err + } + if len(args) == 2 { + for _, c := range m.Cells { + if strings.EqualFold(c.From, args[0]) && strings.EqualFold(c.To, args[1]) { + return output(c, func(w io.Writer) { printCell(w, c) }) + } + } + return fmt.Errorf("no pair %s → %s (zones: %s, and firewall)", args[0], args[1], strings.Join(m.Zones, ", ")) + } + return output(m, func(w io.Writer) { printMatrix(w, m) }) + }, + } +} + +func printMatrix(w io.Writer, m api.Matrix) { + 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) + for _, from := range cols { + fmt.Fprint(w, from) + 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" + } + } + fmt.Fprintf(w, "\t%s", text) + } + fmt.Fprintln(w) + } + 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) + } + fmt.Fprintf(w, " %s\tthe rest\t%s\n", c.Verdict, c.DecidedBy) +} diff --git a/docs/api.md b/docs/api.md index 1f0b48d..2f088b4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,6 +77,7 @@ Creating answers 201, other changes 200. | `GET /healthz` | `{"ok": true, "version"}`, without authentication | | `GET /status` | `build`, `started`, `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 /ruleset` | the nftables ruleset Housecarl builds, as text | | `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 40d6540..63a76c1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -138,6 +138,7 @@ by name. | `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets | | `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)) | +| `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)) | | `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 b8c3950..ead7b87 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -235,6 +235,47 @@ TCP and UDP connections forwarded between those devices skip the rules, which helps on a slow CPU. It needs exact device names. Counters of forward rules then only count each connection's first packets. +## The zone matrix + +The Matrix page (`hc matrix`) sums up what the rules do to **new +connections** between zones: a row per zone the traffic comes from, a +column per zone it goes to, and the firewall itself (to it: the input +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 - +``` + +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 part of its traffic (some addresses, services, + times, rates, a quota) is an **exception** (+N), and the walk goes on; +* what's left falls to the deciding rule or the chain's policy. + +`hc matrix guest wan` (or clicking the cell) lists the exceptions in +order: + +``` +guest → wan (the forward chain), new connections: + allowed http, https rule forward #14 + blocked the rest forward policy +``` + +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 +networks, mixed lists and dynamic sets might, so they stay. Replies and +established connections aren't shown: the built-in lets them through. In +review mode, the matrix shows your pending changes. + ## The ruleset `hc ruleset` prints what Housecarl builds; the web UI and `hc rule list` diff --git a/internal/api/types.go b/internal/api/types.go index 9c51e2b..a2b1f3d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -422,3 +422,38 @@ type TraceStatus struct { Packets []TracePacket `json:"packets"` Error string `json:"error,omitempty"` } + +// Matrix is what the rules do to new connections between zones: +// zone → zone (the forward chain), zone → the firewall (input) and the +// firewall → zone (output). "firewall" stands for the machine itself. +type Matrix struct { + Zones []string `json:"zones"` + Cells []MatrixCell `json:"cells"` +} + +// MatrixCell is one pair: what happens to new connections from From to +// To that no exception covers (Verdict, decided by DecidedBy), and the +// exceptions: rules and built-ins that decide for some of them (some +// addresses, services, times…), in the order they are checked. +type MatrixCell struct { + From string `json:"from"` + To string `json:"to"` + Chain string `json:"chain"` + Verdict string `json:"verdict"` // allowed or blocked + DecidedBy string `json:"decided_by"` + RuleID int64 `json:"rule_id,omitempty"` // the deciding rule + Exceptions []MatrixException `json:"exceptions"` +} + +// MatrixException is a rule (or built-in) that decides for part of a +// pair's traffic. +type MatrixException struct { + RuleID int64 `json:"rule_id,omitempty"` // 0: a built-in, or port forwards + Chain string `json:"chain"` + Label string `json:"label"` + // Verdict is allowed, blocked, logged (and goes on), or unknown (a raw + // rule Housecarl can't read). + Verdict string `json:"verdict"` + // What it covers: "from trusted · ssh", "Monday 08:00–17:00". + What string `json:"what"` +} diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go new file mode 100644 index 0000000..0e7a47a --- /dev/null +++ b/internal/matrix/matrix.go @@ -0,0 +1,323 @@ +// Package matrix works out what the rules do to new connections between +// zones, for an overview: for each pair it walks the rules as nftables +// would, in order. A rule that applies to the whole pair decides it; one +// that applies to some of its traffic (some addresses, services, times, +// rates…) is an exception, and the walk goes on; what is left falls to +// the chain's policy. +package matrix + +import ( + "fmt" + "net/netip" + "strings" + + "housecarl/internal/api" + "housecarl/internal/model" +) + +// Firewall stands for the machine itself in the matrix. +const Firewall = "firewall" + +// Options tell Build about the machine. +type Options struct { + // Connected maps the networks directly connected to the firewall's + // interfaces to their zones. A rule whose addresses all lie in other + // zones' networks can't apply to a pair, and is left out of it. + Connected map[netip.Prefix]string +} + +// Build works out the matrix of a configuration. +func Build(st *model.State, opts ...Options) api.Matrix { + var o Options + if len(opts) > 0 { + o = opts[0] + } + m := api.Matrix{Zones: []string{}, Cells: []api.MatrixCell{}} + for _, z := range st.Zones { + m.Zones = append(m.Zones, z.Name) + } + for _, from := range m.Zones { + for _, to := range m.Zones { + m.Cells = append(m.Cells, cell(st, o, model.ChainForward, from, to)) + } + m.Cells = append(m.Cells, cell(st, o, model.ChainInput, from, Firewall)) + } + for _, to := range m.Zones { + m.Cells = append(m.Cells, cell(st, o, model.ChainOutput, Firewall, to)) + } + return m +} + +// Cell returns one pair (from or to may be Firewall). +func Cell(st *model.State, o Options, from, to string) (api.MatrixCell, bool) { + known := func(z string) bool { return st.Zone(z) != nil } + switch { + case from == Firewall && known(to): + return cell(st, o, model.ChainOutput, from, to), true + case to == Firewall && known(from): + return cell(st, o, model.ChainInput, from, to), true + case known(from) && known(to): + return cell(st, o, model.ChainForward, from, to), true + } + return api.MatrixCell{}, false +} + +func cell(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) + external := fromZone != nil && fromZone.External + // What comes in passes the early chain first. + 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)"}) + } + if decided := walk(st, o, &c, model.ChainEarly, from, ""); decided { + return c + } + } + // The built-ins that let some new connections in. + 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"}) + } + if b.PingInternal && !external { + c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: ping", Verdict: "allowed", What: "ping (ICMP echo)"}) + } + 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"}) + } + case model.ChainForward: + if n := forwards(st); n > 0 && external && !st.Zone(to).External { + 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])}) + } + 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"}) + } + } + if walk(st, o, &c, chain, from, to) { + return c + } + 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" + return c +} + +func forwards(st *model.State) int { + n := 0 + for _, f := range st.Forwards { + if !f.Disabled && f.To != "" { + n++ + } + } + return n +} + +// walk goes through a chain's rules for the pair, as nftables checks +// them (those that check every packet first). It reports whether a rule +// decided the whole pair. +func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string) bool { + var rules []*model.Rule + for _, every := range []bool{true, false} { + for _, r := range st.Rules { + if r.Chain == chain && !r.Disabled && r.ChecksEveryPacket() == every { + rules = append(rules, r) + } + } + } + for _, r := range rules { + 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}) + continue + } + if !zones(r, from, to) || !forNew(r) { + continue + } + // Addresses that all live behind other zones can't come from (or + // go to) this pair's. + if !r.NotSources && elsewhere(st, o, r.Sources, from) || !r.NotDests && to != Firewall && to != "" && elsewhere(st, o, r.Destinations, to) { + continue + } + if chain == model.ChainEarly && r.Action != model.ActionDrop && r.Action != model.ActionReject { + continue // an early accept decides nothing: input or forward still check + } + v := verdictOf(r.Action) + what := conditions(st, r) + if r.Action == model.ActionLog || what != "" { + if what == "" { + what = "everything" + } + c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: v, What: what}) + continue + } + c.Verdict, c.DecidedBy, c.RuleID = v, "rule "+r.Label(), r.ID + return true + } + return false +} + +func verdictOf(action string) string { + switch action { + case model.ActionAccept: // and the accept policy + return "allowed" + case model.ActionLog: + return "logged" + } + return "blocked" +} + +// elsewhere reports whether every address of refs (addresses, sets) lies +// in networks connected to zones other than zone: then traffic of zone +// can't have them. An empty list, a dynamic set, or an address the +// firewall isn't directly connected to (a routed network) might. +func elsewhere(st *model.State, o Options, refs []string, zone string) bool { + if len(refs) == 0 || len(o.Connected) == 0 || zone == Firewall { + return false + } + var entries []string + for _, r := range refs { + if s := st.Set(r); s != nil { + if s.Dynamic { + return false + } + entries = append(entries, s.Entries...) + } else { + entries = append(entries, r) + } + } + if len(entries) == 0 { + return false + } + for _, e := range entries { + a, err := model.ParseAddress(e) + if err != nil { + return false + } + first, last := a.Prefix.Masked().Addr(), lastAddr(a.Prefix) + if a.First.IsValid() { + first, last = a.First, a.End + } + owner := "" + for net, z := range o.Connected { + if net.Contains(first) && net.Contains(last) { + owner = z + break + } + } + if owner == "" || strings.EqualFold(owner, zone) { + return false + } + } + return true +} + +// lastAddr is a prefix's last address. +func lastAddr(p netip.Prefix) netip.Addr { + b := p.Masked().Addr().AsSlice() + for i := p.Bits(); i < len(b)*8; i++ { + b[i/8] |= 0x80 >> (i % 8) + } + a, _ := netip.AddrFromSlice(b) + return a +} + +// zones reports whether a rule's zones take in the pair. +func zones(r *model.Rule, from, to string) bool { + in := func(list []string, not bool, z string) bool { + if len(list) == 0 { + return true + } + for _, x := range list { + if strings.EqualFold(x, z) { + return !not + } + } + return not + } + if from == Firewall { + if len(r.InZones) > 0 { + return false + } + } else if !in(r.InZones, r.NotIn, from) { + return false + } + if to == "" || to == Firewall { + return len(r.OutZones) == 0 + } + return in(r.OutZones, r.NotOut, to) +} + +// forNew reports whether a rule can match a new connection's first +// packet (its connection states allow "new"). +func forNew(r *model.Rule) bool { + if len(r.CtState) == 0 { + return true + } + for _, s := range r.CtState { + if s == "new" { + return true + } + } + return false +} + +// 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 { + var parts []string + list := func(not bool, xs []string) string { + s := strings.Join(xs, ", ") + if not { + return "any but " + s + } + return s + } + if len(r.Sources) > 0 { + parts = append(parts, "from "+list(r.NotSources, r.Sources)) + } + if len(r.Destinations) > 0 { + parts = append(parts, "to "+list(r.NotDests, r.Destinations)) + } + if len(r.Services) > 0 { + parts = append(parts, strings.Join(r.Services, ", ")) + } + if r.Family != "" { + parts = append(parts, r.Family+" only") + } + if len(r.CtStatus) > 0 { + parts = append(parts, "connections that were "+strings.Join(r.CtStatus, ", ")+"'d") + } + if len(r.Days) > 0 { + parts = append(parts, strings.Join(r.Days, ", ")) + } + if r.HourFrom != "" { + parts = append(parts, r.HourFrom+"–"+r.HourTo) + } + if l := r.Limit; l != nil { + unit := "" + if l.Unit != "" && l.Unit != "packets" { + unit = " " + l.Unit + } + rate := fmt.Sprintf("%d%s/%s", l.Rate, unit, l.Per) + if l.Over { + parts = append(parts, "beyond "+rate) + } else { + parts = append(parts, "within "+rate) + } + } + if r.ConnLimit > 0 { + parts = append(parts, fmt.Sprintf("sources with over %d connections", r.ConnLimit)) + } + if q := r.Quota; q != nil { + if q.Over { + parts = append(parts, "once its quota is used up") + } else { + parts = append(parts, "while its quota lasts") + } + } + return strings.Join(parts, " · ") +} diff --git a/internal/matrix/matrix_test.go b/internal/matrix/matrix_test.go new file mode 100644 index 0000000..76edfa5 --- /dev/null +++ b/internal/matrix/matrix_test.go @@ -0,0 +1,112 @@ +package matrix + +import ( + "fmt" + "net/netip" + "strings" + "testing" + + "housecarl/internal/api" + "housecarl/internal/model" +) + +func state() *model.State { + st := model.NewState() + st.Zones = []*model.Zone{ + {ID: 1, Name: "guest", Interfaces: []string{"eth2"}}, + {ID: 2, Name: "lan", Interfaces: []string{"eth1"}}, + {ID: 3, Name: "wan", Interfaces: []string{"wan"}, External: true}, + } + st.Sets = []*model.AddressSet{{ID: 4, Name: "admins", Entries: []string{"192.168.1.0/28"}}} + pos := 0.0 + rule := func(r model.Rule) { + pos++ + r.ID, r.Position = int64(10+pos), pos + st.Rules = append(st.Rules, &r) + } + rule(model.Rule{Chain: "input", Match: model.Match{Sources: []string{"admins"}, Services: []string{"ssh"}}, Action: "accept", Comment: "SSH"}) + rule(model.Rule{Chain: "input", Match: model.Match{InZones: []string{"lan", "guest"}, Services: []string{"dns"}}, Action: "accept"}) + rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: "accept", Comment: "LAN out"}) + rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"guest"}, OutZones: []string{"wan"}, Services: []string{"http", "https"}}, Action: "accept"}) + rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"wan"}}, Action: "drop", Comment: "nothing in"}) + rule(model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"lan"}}, CtState: []string{"established"}, Action: "drop"}) // not for new + rule(model.Rule{Chain: "output", Match: model.Match{OutZones: []string{"wan"}}, Action: "drop", Disabled: true}) + st.Forwards = []*model.PortForward{{ID: 30, Name: "web", Protocols: []string{"tcp"}, Ports: "443", To: "192.168.1.10"}} + st.Settings.Builtins.MSSClamp = true + return st +} + +func find(m api.Matrix, from, to string) api.MatrixCell { + for _, c := range m.Cells { + if c.From == from && c.To == to { + return c + } + } + return api.MatrixCell{} +} + +func TestMatrix(t *testing.T) { + m := Build(state()) + if strings.Join(m.Zones, ",") != "guest,lan,wan" || len(m.Cells) != 3*3+3+3 { + t.Fatalf("zones %v, %d cells", m.Zones, len(m.Cells)) + } + for _, c := range []struct { + from, to, verdict, decided string + exceptions int + }{ + {"lan", "wan", "allowed", "rule forward #13 (LAN out)", 0}, + {"guest", "wan", "blocked", "forward policy", 1}, // only web + {"wan", "lan", "blocked", "rule forward #15 (nothing in)", 2}, // antispoof, port forwards come first + {"lan", "guest", "blocked", "forward policy", 0}, // the established-only rule is no exception + {"lan", Firewall, "blocked", "input policy", 4}, // icmpv6, ping, ssh from admins, dns + {"wan", Firewall, "blocked", "input policy", 4}, // antispoof, icmpv6, dhcpv6, ssh from admins + {Firewall, "wan", "allowed", "output policy", 0}, // the drop is disabled + } { + got := find(m, c.from, c.to) + if got.Verdict != c.verdict || got.DecidedBy != c.decided || len(got.Exceptions) != c.exceptions { + t.Errorf("%s → %s: %s by %s, %d exceptions %+v; want %s by %s, %d", c.from, c.to, got.Verdict, got.DecidedBy, len(got.Exceptions), got.Exceptions, c.verdict, c.decided, c.exceptions) + } + } + g := find(m, "guest", "wan").Exceptions[0] + if g.Verdict != "allowed" || g.What != "http, https" || g.RuleID != 14 { + t.Errorf("the guest exception: %+v", g) + } + if e := find(m, "wan", "lan").Exceptions; e[0].Label != "built-in: antispoof" || e[1].Label != "port forwards" { + t.Errorf("wan → lan exceptions: %+v", e) + } + if _, ok := Cell(state(), Options{}, "lan", "nowhere"); ok { + t.Error("an unknown zone") + } +} + +// Rules whose addresses all live behind another zone are left out of a +// pair; routed (unknown) networks, and mixed lists, stay. +func TestConnected(t *testing.T) { + st := state() + st.Sets = append(st.Sets, &model.AddressSet{ID: 5, Name: "guest_net", Entries: []string{"192.168.2.0/24"}}, + &model.AddressSet{ID: 6, Name: "mixed", Entries: []string{"192.168.2.0/24", "10.20.30.0/24"}}) + st.Rules = append(st.Rules, + &model.Rule{ID: 40, Chain: "forward", Position: 10, Match: model.Match{Sources: []string{"guest_net"}}, Action: "drop", Comment: "guests"}, + &model.Rule{ID: 41, Chain: "forward", Position: 11, Match: model.Match{Destinations: []string{"192.168.2.5"}}, Action: "accept", Comment: "to a guest"}, + &model.Rule{ID: 42, Chain: "forward", Position: 12, Match: model.Match{Sources: []string{"mixed"}}, Action: "drop", Comment: "mixed"}) + o := Options{Connected: map[netip.Prefix]string{netip.MustParsePrefix("192.168.1.0/24"): "lan", netip.MustParsePrefix("192.168.2.0/24"): "guest"}} + ids := func(c api.MatrixCell) []int64 { + var out []int64 + for _, e := range c.Exceptions { + out = append(out, e.RuleID) + } + return out + } + lanWan, _ := Cell(st, o, "lan", "wan") + if fmt.Sprint(ids(lanWan)) != "[]" { + t.Errorf("lan → wan: %v", ids(lanWan)) // decided by "LAN out" before the others anyway + } + lanGuest, _ := Cell(st, o, "lan", "guest") + if fmt.Sprint(ids(lanGuest)) != "[41 42]" { + t.Errorf("lan → guest: %v", ids(lanGuest)) // not "guests": their addresses are guest's + } + guestLan, _ := Cell(st, o, "guest", "lan") + if fmt.Sprint(ids(guestLan)) != "[40 42]" { + t.Errorf("guest → lan: %v", ids(guestLan)) // not "to a guest": it is in guest, not lan + } +} diff --git a/internal/server/h_config.go b/internal/server/h_config.go index fb9d377..ac0acd0 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "net/netip" "sort" "strings" @@ -12,6 +13,7 @@ import ( "housecarl/internal/api" "housecarl/internal/auth" "housecarl/internal/engine" + "housecarl/internal/matrix" "housecarl/internal/model" "housecarl/internal/store" ) @@ -416,6 +418,41 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth. return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data}) } +// ---- the zone matrix + +// zoneMatrix answers what the rules do to new connections between zones +// (internal/matrix); with a changeset named, as it will make them. +func (s *Server) zoneMatrix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.ResConfig, false); err != nil { + return err + } + _, st, _, err := s.viewState(w, r) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, matrix.Build(st, matrix.Options{Connected: s.connected(st)})) + return nil +} + +// 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 { + out := map[netip.Prefix]string{} + for _, i := range s.hostInterfaces(st) { + if i.Zone == "" { + continue + } + for _, a := range i.Addresses { + p, err := netip.ParsePrefix(a) + if err != nil || p.Addr().IsLinkLocalUnicast() || p.Addr().IsLoopback() { + continue + } + out[p.Masked()] = i.Zone + } + } + return out +} + // ---- the ruleset func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { diff --git a/internal/server/routes.go b/internal/server/routes.go index 1a6743f..254bb61 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -63,6 +63,7 @@ func (s *Server) routes() { m.HandleFunc("DELETE "+p+"/{kind}/{ref}", s.authed(s.deleteObject)) m.HandleFunc("POST "+p+"/{kind}/{ref}/move", s.authed(s.moveObject)) m.HandleFunc("GET "+p+"/ruleset", s.authed(s.ruleset)) + m.HandleFunc("GET "+p+"/matrix", s.authed(s.zoneMatrix)) m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory)) m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory)) m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index ddb5881..273915b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -848,6 +848,34 @@ func TestStagedUndo(t *testing.T) { } } +// TestMatrix: the zone matrix, now and as a changeset will make it. +func TestMatrixEndpoint(t *testing.T) { + e := newEnv(t) + e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil) + e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil) + var m api.Matrix + e.call(e.token, "GET", "/matrix", nil, &m) + cell := func(from, to string) api.MatrixCell { + for _, c := range m.Cells { + if c.From == from && c.To == to { + return c + } + } + return api.MatrixCell{} + } + if c := cell("lan", "wan"); c.Verdict != "blocked" || c.DecidedBy != "forward policy" { + t.Errorf("lan → wan: %+v", c) + } + var cs model.Changeset + e.call(e.token, "POST", "/changesets", nil, &cs) + q := "?changeset=" + itoa(cs.ID) + e.call(e.token, "POST", "/rules"+q, model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: "accept"}, nil) + e.call(e.token, "GET", "/matrix"+q, nil, &m) + if c := cell("lan", "wan"); c.Verdict != "allowed" { + t.Errorf("lan → wan with the changeset: %+v", c) + } +} + // TestHostProblems: an enabled nftables.service, and an imported table // still in force, are warned about. func TestHostProblems(t *testing.T) { diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index 4a3c585..7889b22 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -5,6 +5,7 @@ import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDra import * as changes from "./pages/changes.js"; import * as connections from "./pages/connections.js"; import * as tracePage from "./pages/trace.js"; +import * as matrixPage from "./pages/matrix.js"; import * as dashboard from "./pages/dashboard.js"; import * as rules from "./pages/rules.js"; import * as nat from "./pages/nat.js"; @@ -24,6 +25,7 @@ const routes = [ { pattern: /^$/, page: dashboard, nav: "dashboard" }, { pattern: /^rules$/, page: rules, nav: "rules" }, { pattern: /^nat$/, page: nat, nav: "nat" }, + { pattern: /^matrix$/, page: matrixPage, nav: "matrix" }, { pattern: /^zones$/, page: zones, nav: "zones" }, { pattern: /^sets$/, page: sets, nav: "sets" }, { pattern: /^services$/, page: services, nav: "services" }, @@ -45,6 +47,7 @@ const routes = [ const navItems = [ ["dashboard", "#/", "Dashboard"], ["rules", "#/rules", "Rules", "config"], + ["matrix", "#/matrix", "Matrix", "config"], ["nat", "#/nat", "NAT", "config"], { menu: "Objects", items: [ ["zones", "#/zones", "Zones", "config"], diff --git a/internal/web/static/assets/pages/matrix.js b/internal/web/static/assets/pages/matrix.js new file mode 100644 index 0000000..d74b491 --- /dev/null +++ b/internal/web/static/assets/pages/matrix.js @@ -0,0 +1,38 @@ +// Matrix: what the rules do to new connections between zones, and to and +// from the firewall itself. +import { h, mount, modal, badge } from "../ui.js"; +import { get, state } from "../api.js"; + +export async function render(el) { + const m = await get("/matrix"); + const cols = [...m.zones, "firewall"]; + const cells = new Map(m.cells.map((c) => [c.from + "\u0000" + c.to, c])); + const pending = state.draft && !state.immediate && (state.draft.ops || []).length; + 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."))), + 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" }, + h("thead", null, h("tr", null, h("th", { class: "corner" }, "from ↓ to →"), cols.map((c) => h("th", null, name(c))))), + 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)); + })))))), + 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."))); +} + +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]); + 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 }); +} diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 6d2b105..789f25d 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -419,3 +419,14 @@ table.trace-steps { width: 100%; margin-top: 6px; } table.trace-steps td { padding: 3px 8px; border-top: 1px solid var(--border); } tr.step-drop td { background: var(--del); } tr.step-accept td { background: var(--add); } +/* The zone matrix: a cell per pair, coloured by what happens. */ +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; } +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); } +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 ba5fdb3..21e8d1c 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -272,6 +272,18 @@ run "the reset is audited" 0 H audit --action quota. contains "quota.reset" run "the quota rule goes" 0 H rule delete -y "$QR" +# ---- the zone matrix, of the imported ruleset + +run "the matrix" 0 H matrix +contains "^firewall *allow" +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" +not_contains "wg0_net" +run "the internet to the firewall" 0 H matrix wan firewall +contains "blocked *sources that can't come from outside" +contains "allowed *udp/51820" + # ---- connections and tracing # A connection that stays open, masqueraded on its way out.